refactored timetable
This commit is contained in:
@@ -8,7 +8,17 @@ import 'getCustomTimetableEventResponse.dart';
|
|||||||
class GetCustomTimetableEventCache extends RequestCache<GetCustomTimetableEventResponse> {
|
class GetCustomTimetableEventCache extends RequestCache<GetCustomTimetableEventResponse> {
|
||||||
GetCustomTimetableEventParams params;
|
GetCustomTimetableEventParams params;
|
||||||
|
|
||||||
GetCustomTimetableEventCache(this.params, {onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) {
|
GetCustomTimetableEventCache(
|
||||||
|
this.params, {
|
||||||
|
void Function(GetCustomTimetableEventResponse)? onUpdate,
|
||||||
|
void Function(Exception)? onError,
|
||||||
|
bool? renew,
|
||||||
|
}) : super(
|
||||||
|
RequestCache.cacheMinute,
|
||||||
|
onUpdate,
|
||||||
|
onError: onError ?? RequestCache.ignore,
|
||||||
|
renew: renew,
|
||||||
|
) {
|
||||||
start('customTimetableEvents');
|
start('customTimetableEvents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:localstore/localstore.dart';
|
import 'package:localstore/localstore.dart';
|
||||||
@@ -17,11 +18,20 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
void Function(Exception) onError;
|
void Function(Exception) onError;
|
||||||
bool? renew;
|
bool? renew;
|
||||||
|
|
||||||
|
final Completer<void> _ready = Completer<void>();
|
||||||
|
|
||||||
|
/// Resolves when [start] has finished, regardless of whether the network
|
||||||
|
/// call succeeded, failed, or was skipped due to a fresh cache. Callers
|
||||||
|
/// can await this to know when both the cache lookup and the network
|
||||||
|
/// attempt have settled.
|
||||||
|
Future<void> get ready => _ready.future;
|
||||||
|
|
||||||
RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false});
|
RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false});
|
||||||
|
|
||||||
static void ignore(Exception e) {}
|
static void ignore(Exception e) {}
|
||||||
|
|
||||||
Future<void> start(String document) async {
|
Future<void> start(String document) async {
|
||||||
|
try {
|
||||||
final tableData = await Localstore.instance.collection(collection).doc(document).get();
|
final tableData = await Localstore.instance.collection(collection).doc(document).get();
|
||||||
if (tableData != null) {
|
if (tableData != null) {
|
||||||
onUpdate?.call(onLocalData(tableData['json']));
|
onUpdate?.call(onLocalData(tableData['json']));
|
||||||
@@ -41,6 +51,9 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
onError(e);
|
onError(e);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!_ready.isCompleted) _ready.complete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
T onLocalData(String json);
|
T onLocalData(String json);
|
||||||
|
|||||||
@@ -14,11 +14,23 @@ class Authenticate extends WebuntisApi {
|
|||||||
@override
|
@override
|
||||||
Future<AuthenticateResponse> run() async {
|
Future<AuthenticateResponse> run() async {
|
||||||
awaitingResponse = true;
|
awaitingResponse = true;
|
||||||
|
try {
|
||||||
var rawAnswer = await query(this);
|
var rawAnswer = await query(this);
|
||||||
AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result']));
|
AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result']));
|
||||||
_lastResponse = response;
|
_lastResponse = response;
|
||||||
if(!awaitedResponse.isCompleted) awaitedResponse.complete();
|
if(!awaitedResponse.isCompleted) awaitedResponse.complete();
|
||||||
return response;
|
return response;
|
||||||
|
} catch (e) {
|
||||||
|
// Surface the error to anyone waiting on the current completer, then
|
||||||
|
// install a fresh one so a future attempt can succeed. Without this,
|
||||||
|
// any later call to getSession() would hang forever on a completer
|
||||||
|
// that is already settled with no listeners (or never settles at all).
|
||||||
|
if(!awaitedResponse.isCompleted) awaitedResponse.completeError(e);
|
||||||
|
awaitedResponse = Completer();
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
awaitingResponse = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool awaitingResponse = false;
|
static bool awaitingResponse = false;
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import 'getHolidays.dart';
|
|||||||
import 'getHolidaysResponse.dart';
|
import 'getHolidaysResponse.dart';
|
||||||
|
|
||||||
class GetHolidaysCache extends RequestCache<GetHolidaysResponse> {
|
class GetHolidaysCache extends RequestCache<GetHolidaysResponse> {
|
||||||
GetHolidaysCache({void Function(GetHolidaysResponse)? onUpdate}) : super(RequestCache.cacheDay, onUpdate) {
|
GetHolidaysCache({
|
||||||
|
void Function(GetHolidaysResponse)? onUpdate,
|
||||||
|
void Function(Exception)? onError,
|
||||||
|
bool? renew,
|
||||||
|
}) : super(
|
||||||
|
RequestCache.cacheDay,
|
||||||
|
onUpdate,
|
||||||
|
onError: onError ?? RequestCache.ignore,
|
||||||
|
renew: renew,
|
||||||
|
) {
|
||||||
start('wu-holidays');
|
start('wu-holidays');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import 'getRooms.dart';
|
|||||||
import 'getRoomsResponse.dart';
|
import 'getRoomsResponse.dart';
|
||||||
|
|
||||||
class GetRoomsCache extends RequestCache<GetRoomsResponse> {
|
class GetRoomsCache extends RequestCache<GetRoomsResponse> {
|
||||||
GetRoomsCache({void Function(GetRoomsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) {
|
GetRoomsCache({
|
||||||
|
void Function(GetRoomsResponse)? onUpdate,
|
||||||
|
void Function(Exception)? onError,
|
||||||
|
bool? renew,
|
||||||
|
}) : super(
|
||||||
|
RequestCache.cacheHour,
|
||||||
|
onUpdate,
|
||||||
|
onError: onError ?? RequestCache.ignore,
|
||||||
|
renew: renew,
|
||||||
|
) {
|
||||||
start('wu-rooms');
|
start('wu-rooms');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import 'getSubjects.dart';
|
|||||||
import 'getSubjectsResponse.dart';
|
import 'getSubjectsResponse.dart';
|
||||||
|
|
||||||
class GetSubjectsCache extends RequestCache<GetSubjectsResponse> {
|
class GetSubjectsCache extends RequestCache<GetSubjectsResponse> {
|
||||||
GetSubjectsCache({void Function(GetSubjectsResponse)? onUpdate}) : super(RequestCache.cacheHour, onUpdate) {
|
GetSubjectsCache({
|
||||||
|
void Function(GetSubjectsResponse)? onUpdate,
|
||||||
|
void Function(Exception)? onError,
|
||||||
|
bool? renew,
|
||||||
|
}) : super(
|
||||||
|
RequestCache.cacheHour,
|
||||||
|
onUpdate,
|
||||||
|
onError: onError ?? RequestCache.ignore,
|
||||||
|
renew: renew,
|
||||||
|
) {
|
||||||
start('wu-subjects');
|
start('wu-subjects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import '../../webuntisApi.dart';
|
||||||
|
import 'getTimegridUnitsResponse.dart';
|
||||||
|
|
||||||
|
class GetTimegridUnits extends WebuntisApi {
|
||||||
|
GetTimegridUnits() : super('getTimegridUnits', null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GetTimegridUnitsResponse> run() async {
|
||||||
|
var rawAnswer = await query(this);
|
||||||
|
try {
|
||||||
|
return finalize(GetTimegridUnitsResponse.fromJson(jsonDecode(rawAnswer)));
|
||||||
|
} catch (e, trace) {
|
||||||
|
log(trace.toString());
|
||||||
|
log('Failed to parse getTimegridUnits data with server response: $rawAnswer');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Failed to parse getTimegridUnits server response: $rawAnswer');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../../../requestCache.dart';
|
||||||
|
import 'getTimegridUnits.dart';
|
||||||
|
import 'getTimegridUnitsResponse.dart';
|
||||||
|
|
||||||
|
class GetTimegridUnitsCache extends RequestCache<GetTimegridUnitsResponse> {
|
||||||
|
GetTimegridUnitsCache({
|
||||||
|
void Function(GetTimegridUnitsResponse)? onUpdate,
|
||||||
|
bool? renew,
|
||||||
|
}) : super(RequestCache.cacheDay, onUpdate, renew: renew) {
|
||||||
|
start('wu-timegrid');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GetTimegridUnitsResponse> onLoad() => GetTimegridUnits().run();
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetTimegridUnitsResponse onLocalData(String json) => GetTimegridUnitsResponse.fromJson(jsonDecode(json));
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../apiResponse.dart';
|
||||||
|
|
||||||
|
part 'getTimegridUnitsResponse.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class GetTimegridUnitsResponse extends ApiResponse {
|
||||||
|
List<GetTimegridUnitsResponseDay> result;
|
||||||
|
|
||||||
|
GetTimegridUnitsResponse(this.result);
|
||||||
|
|
||||||
|
factory GetTimegridUnitsResponse.fromJson(Map<String, dynamic> json) => _$GetTimegridUnitsResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$GetTimegridUnitsResponseToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class GetTimegridUnitsResponseDay {
|
||||||
|
int day;
|
||||||
|
List<GetTimegridUnitsResponseUnit> timeUnits;
|
||||||
|
|
||||||
|
GetTimegridUnitsResponseDay(this.day, this.timeUnits);
|
||||||
|
|
||||||
|
factory GetTimegridUnitsResponseDay.fromJson(Map<String, dynamic> json) => _$GetTimegridUnitsResponseDayFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$GetTimegridUnitsResponseDayToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class GetTimegridUnitsResponseUnit {
|
||||||
|
String name;
|
||||||
|
int startTime;
|
||||||
|
int endTime;
|
||||||
|
|
||||||
|
GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime);
|
||||||
|
|
||||||
|
factory GetTimegridUnitsResponseUnit.fromJson(Map<String, dynamic> json) => _$GetTimegridUnitsResponseUnitFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$GetTimegridUnitsResponseUnitToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'getTimegridUnitsResponse.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) =>
|
||||||
|
GetTimegridUnitsResponse(
|
||||||
|
(json['result'] as List<dynamic>)
|
||||||
|
.map(
|
||||||
|
(e) => GetTimegridUnitsResponseDay.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> _$GetTimegridUnitsResponseToJson(
|
||||||
|
GetTimegridUnitsResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'result': instance.result.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => GetTimegridUnitsResponseDay(
|
||||||
|
(json['day'] as num).toInt(),
|
||||||
|
(json['timeUnits'] as List<dynamic>)
|
||||||
|
.map(
|
||||||
|
(e) => GetTimegridUnitsResponseUnit.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$GetTimegridUnitsResponseDayToJson(
|
||||||
|
GetTimegridUnitsResponseDay instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'day': instance.day,
|
||||||
|
'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => GetTimegridUnitsResponseUnit(
|
||||||
|
json['name'] as String,
|
||||||
|
(json['startTime'] as num).toInt(),
|
||||||
|
(json['endTime'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$GetTimegridUnitsResponseUnitToJson(
|
||||||
|
GetTimegridUnitsResponseUnit instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'name': instance.name,
|
||||||
|
'startTime': instance.startTime,
|
||||||
|
'endTime': instance.endTime,
|
||||||
|
};
|
||||||
@@ -15,7 +15,13 @@ class GetTimetableCache extends RequestCache<GetTimetableResponse> {
|
|||||||
void Function(Exception)? onError,
|
void Function(Exception)? onError,
|
||||||
required this.startdate,
|
required this.startdate,
|
||||||
required this.enddate,
|
required this.enddate,
|
||||||
}) : super(RequestCache.cacheMinute, onUpdate, onError: onError ?? RequestCache.ignore) {
|
bool? renew,
|
||||||
|
}) : super(
|
||||||
|
RequestCache.cacheMinute,
|
||||||
|
onUpdate,
|
||||||
|
onError: onError ?? RequestCache.ignore,
|
||||||
|
renew: renew,
|
||||||
|
) {
|
||||||
start('wu-timetable-$startdate-$enddate');
|
start('wu-timetable-$startdate-$enddate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ class TimetableBloc extends LoadableHydratedBloc<TimetableEvent, TimetableState,
|
|||||||
|
|
||||||
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
/// Set by [retry] to force the next [gatherData] to bypass cache freshness
|
||||||
|
/// checks and actually hit the network. Cleared at the top of [gatherData].
|
||||||
|
bool _forceRenew = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void retry() {
|
||||||
|
_forceRenew = true;
|
||||||
|
super.retry();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TimetableRepository repository() => TimetableRepository();
|
TimetableRepository repository() => TimetableRepository();
|
||||||
|
|
||||||
@@ -35,11 +45,23 @@ class TimetableBloc extends LoadableHydratedBloc<TimetableEvent, TimetableState,
|
|||||||
@override
|
@override
|
||||||
Future<void> gatherData() async {
|
Future<void> gatherData() async {
|
||||||
final initial = innerState ?? fromNothing();
|
final initial = innerState ?? fromNothing();
|
||||||
|
final renew = _forceRenew;
|
||||||
|
_forceRenew = false;
|
||||||
|
|
||||||
|
Object? firstError;
|
||||||
|
void recordError(Object e) {
|
||||||
|
firstError ??= e;
|
||||||
|
}
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_loadCurrentWeek(initial.startDate, initial.endDate),
|
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError, renew: renew),
|
||||||
_loadStaticReferenceData(),
|
_loadStaticReferenceData(onError: recordError, renew: renew),
|
||||||
_loadCustomEvents(),
|
_loadCustomEvents(onError: recordError, renew: renew),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (firstError != null) throw firstError!;
|
||||||
|
|
||||||
|
add(DataGathered((s) => s));
|
||||||
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
|
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,41 +95,69 @@ class TimetableBloc extends LoadableHydratedBloc<TimetableEvent, TimetableState,
|
|||||||
await _refreshCustomEvents();
|
await _refreshCustomEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCurrentWeek(DateTime startDate, DateTime endDate) async {
|
Future<void> _loadCurrentWeek(
|
||||||
|
DateTime startDate,
|
||||||
|
DateTime endDate, {
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) async {
|
||||||
final requestStart = DateTime.now();
|
final requestStart = DateTime.now();
|
||||||
_lastWeekRequestStart = requestStart;
|
_lastWeekRequestStart = requestStart;
|
||||||
try {
|
try {
|
||||||
final week = await repo.data.getWeek(startDate, endDate);
|
final week = await repo.data.getWeek(startDate, endDate, onError: onError, renew: renew);
|
||||||
if (_lastWeekRequestStart.isAfter(requestStart)) return;
|
if (_lastWeekRequestStart.isAfter(requestStart)) return;
|
||||||
_writeWeekToCache(startDate, week);
|
_writeWeekToCache(startDate, week);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Errors are surfaced via LoadableHydratedBloc.fetch's catchError.
|
onError?.call(e);
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadStaticReferenceData() async {
|
Future<void> _loadStaticReferenceData({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
final (rooms, subjects, schoolHolidays) = await (
|
final (rooms, subjects, schoolHolidays) = await (
|
||||||
repo.data.getRooms(),
|
repo.data.getRooms(onError: onError, renew: renew),
|
||||||
repo.data.getSubjects(),
|
repo.data.getSubjects(onError: onError, renew: renew),
|
||||||
repo.data.getSchoolHolidays(),
|
repo.data.getSchoolHolidays(onError: onError, renew: renew),
|
||||||
).wait;
|
).wait;
|
||||||
|
|
||||||
add(DataGathered((s) => s.copyWith(
|
add(Emit((s) => s.copyWith(
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
subjects: subjects,
|
subjects: subjects,
|
||||||
schoolHolidays: schoolHolidays,
|
schoolHolidays: schoolHolidays,
|
||||||
dataVersion: s.dataVersion + 1,
|
dataVersion: s.dataVersion + 1,
|
||||||
)));
|
)));
|
||||||
|
} catch (e) {
|
||||||
|
onError?.call(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCustomEvents({bool renew = false}) async {
|
try {
|
||||||
final events = await repo.data.getCustomEvents(renew: renew);
|
final timegrid = await repo.data.getTimegrid(renew: renew);
|
||||||
|
add(Emit((s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1)));
|
||||||
|
} catch (_) {
|
||||||
|
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCustomEvents({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final events = await repo.data.getCustomEvents(renew: renew, onError: onError);
|
||||||
|
add(Emit((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1)));
|
||||||
|
} catch (e) {
|
||||||
|
onError?.call(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshCustomEvents() async {
|
||||||
|
final events = await repo.data.getCustomEvents(renew: true);
|
||||||
add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1)));
|
add(DataGathered((s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshCustomEvents() => _loadCustomEvents(renew: true);
|
|
||||||
|
|
||||||
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
|
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
|
||||||
_prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan));
|
_prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan));
|
||||||
_prefetchWeek(start.add(_weekSpan), end.add(_weekSpan));
|
_prefetchWeek(start.add(_weekSpan), end.add(_weekSpan));
|
||||||
@@ -119,7 +169,7 @@ class TimetableBloc extends LoadableHydratedBloc<TimetableEvent, TimetableState,
|
|||||||
|
|
||||||
void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) {
|
void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) {
|
||||||
final key = _weekKeyFormat.format(weekStart);
|
final key = _weekKeyFormat.format(weekStart);
|
||||||
add(DataGathered((s) {
|
add(Emit((s) {
|
||||||
final updated = Map<String, GetTimetableResponse>.of(s.weekCache);
|
final updated = Map<String, GetTimetableResponse>.of(s.weekCache);
|
||||||
updated[key] = week;
|
updated[key] = week;
|
||||||
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
|
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEvent
|
|||||||
import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
import '../../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
||||||
import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
||||||
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
||||||
|
import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart';
|
||||||
import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||||
|
|
||||||
part 'timetable_state.freezed.dart';
|
part 'timetable_state.freezed.dart';
|
||||||
@@ -18,6 +19,7 @@ abstract class TimetableState with _$TimetableState {
|
|||||||
GetRoomsResponse? rooms,
|
GetRoomsResponse? rooms,
|
||||||
GetSubjectsResponse? subjects,
|
GetSubjectsResponse? subjects,
|
||||||
GetHolidaysResponse? schoolHolidays,
|
GetHolidaysResponse? schoolHolidays,
|
||||||
|
GetTimegridUnitsResponse? timegrid,
|
||||||
GetCustomTimetableEventResponse? customEvents,
|
GetCustomTimetableEventResponse? customEvents,
|
||||||
required DateTime startDate,
|
required DateTime startDate,
|
||||||
required DateTime endDate,
|
required DateTime endDate,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$TimetableState {
|
mixin _$TimetableState {
|
||||||
|
|
||||||
Map<String, GetTimetableResponse> get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;
|
Map<String, GetTimetableResponse> get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;
|
||||||
/// Create a copy of TimetableState
|
/// Create a copy of TimetableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -28,16 +28,16 @@ $TimetableStateCopyWith<TimetableState> get copyWith => _$TimetableStateCopyWith
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion);
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)';
|
return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ abstract mixin class $TimetableStateCopyWith<$Res> {
|
|||||||
factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl;
|
factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion
|
Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -65,13 +65,14 @@ class _$TimetableStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of TimetableState
|
/// Create a copy of TimetableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) {
|
@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) {
|
||||||
return _then(_self.copyWith(
|
return _then(_self.copyWith(
|
||||||
weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable
|
weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable
|
||||||
as Map<String, GetTimetableResponse>,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable
|
as Map<String, GetTimetableResponse>,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable
|
||||||
as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable
|
as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable
|
||||||
as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable
|
as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable
|
||||||
as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable
|
as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable
|
||||||
|
as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable
|
||||||
as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable
|
as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable
|
as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable
|
as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable
|
||||||
@@ -160,10 +161,10 @@ return $default(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _TimetableState() when $default != null:
|
case _TimetableState() when $default != null:
|
||||||
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
||||||
return orElse();
|
return orElse();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -181,10 +182,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _TimetableState():
|
case _TimetableState():
|
||||||
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
||||||
throw StateError('Unexpected subclass');
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -201,10 +202,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _TimetableState() when $default != null:
|
case _TimetableState() when $default != null:
|
||||||
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _TimetableState extends TimetableState {
|
class _TimetableState extends TimetableState {
|
||||||
const _TimetableState({final Map<String, GetTimetableResponse> weekCache = const <String, GetTimetableResponse>{}, this.rooms, this.subjects, this.schoolHolidays, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._();
|
const _TimetableState({final Map<String, GetTimetableResponse> weekCache = const <String, GetTimetableResponse>{}, this.rooms, this.subjects, this.schoolHolidays, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._();
|
||||||
factory _TimetableState.fromJson(Map<String, dynamic> json) => _$TimetableStateFromJson(json);
|
factory _TimetableState.fromJson(Map<String, dynamic> json) => _$TimetableStateFromJson(json);
|
||||||
|
|
||||||
final Map<String, GetTimetableResponse> _weekCache;
|
final Map<String, GetTimetableResponse> _weekCache;
|
||||||
@@ -229,6 +230,7 @@ class _TimetableState extends TimetableState {
|
|||||||
@override final GetRoomsResponse? rooms;
|
@override final GetRoomsResponse? rooms;
|
||||||
@override final GetSubjectsResponse? subjects;
|
@override final GetSubjectsResponse? subjects;
|
||||||
@override final GetHolidaysResponse? schoolHolidays;
|
@override final GetHolidaysResponse? schoolHolidays;
|
||||||
|
@override final GetTimegridUnitsResponse? timegrid;
|
||||||
@override final GetCustomTimetableEventResponse? customEvents;
|
@override final GetCustomTimetableEventResponse? customEvents;
|
||||||
@override final DateTime startDate;
|
@override final DateTime startDate;
|
||||||
@override final DateTime endDate;
|
@override final DateTime endDate;
|
||||||
@@ -247,16 +249,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,customEvents,startDate,endDate,dataVersion);
|
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)';
|
return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@ abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCo
|
|||||||
factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl;
|
factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion
|
Map<String, GetTimetableResponse> weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -284,13 +286,14 @@ class __$TimetableStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of TimetableState
|
/// Create a copy of TimetableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) {
|
@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) {
|
||||||
return _then(_TimetableState(
|
return _then(_TimetableState(
|
||||||
weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable
|
weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable
|
||||||
as Map<String, GetTimetableResponse>,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable
|
as Map<String, GetTimetableResponse>,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable
|
||||||
as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable
|
as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable
|
||||||
as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable
|
as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable
|
||||||
as GetHolidaysResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable
|
as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable
|
||||||
|
as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable
|
||||||
as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable
|
as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable
|
as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable
|
as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ _TimetableState _$TimetableStateFromJson(Map<String, dynamic> json) =>
|
|||||||
: GetHolidaysResponse.fromJson(
|
: GetHolidaysResponse.fromJson(
|
||||||
json['schoolHolidays'] as Map<String, dynamic>,
|
json['schoolHolidays'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
|
timegrid: json['timegrid'] == null
|
||||||
|
? null
|
||||||
|
: GetTimegridUnitsResponse.fromJson(
|
||||||
|
json['timegrid'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
customEvents: json['customEvents'] == null
|
customEvents: json['customEvents'] == null
|
||||||
? null
|
? null
|
||||||
: GetCustomTimetableEventResponse.fromJson(
|
: GetCustomTimetableEventResponse.fromJson(
|
||||||
@@ -45,6 +50,7 @@ Map<String, dynamic> _$TimetableStateToJson(_TimetableState instance) =>
|
|||||||
'rooms': instance.rooms,
|
'rooms': instance.rooms,
|
||||||
'subjects': instance.subjects,
|
'subjects': instance.subjects,
|
||||||
'schoolHolidays': instance.schoolHolidays,
|
'schoolHolidays': instance.schoolHolidays,
|
||||||
|
'timegrid': instance.timegrid,
|
||||||
'customEvents': instance.customEvents,
|
'customEvents': instance.customEvents,
|
||||||
'startDate': instance.startDate.toIso8601String(),
|
'startDate': instance.startDate.toIso8601String(),
|
||||||
'endDate': instance.endDate.toIso8601String(),
|
'endDate': instance.endDate.toIso8601String(),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart';
|
import '../../../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart';
|
||||||
@@ -18,6 +16,8 @@ import '../../../../../api/webuntis/queries/getRooms/getRoomsCache.dart';
|
|||||||
import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
import '../../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
||||||
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart';
|
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsCache.dart';
|
||||||
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
import '../../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
||||||
|
import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsCache.dart';
|
||||||
|
import '../../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart';
|
||||||
import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart';
|
import '../../../../../api/webuntis/queries/getTimetable/getTimetableCache.dart';
|
||||||
import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
import '../../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||||
import '../../../../../model/accountData.dart';
|
import '../../../../../model/accountData.dart';
|
||||||
@@ -25,55 +25,116 @@ import '../../../../../model/accountData.dart';
|
|||||||
class TimetableDataProvider {
|
class TimetableDataProvider {
|
||||||
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
|
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
|
||||||
|
|
||||||
Future<GetTimetableResponse> getWeek(DateTime startDate, DateTime endDate) {
|
Future<GetTimetableResponse> getWeek(
|
||||||
final completer = Completer<GetTimetableResponse>();
|
DateTime startDate,
|
||||||
GetTimetableCache(
|
DateTime endDate, {
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) async {
|
||||||
|
GetTimetableResponse? latest;
|
||||||
|
Object? capturedError;
|
||||||
|
final cache = GetTimetableCache(
|
||||||
startdate: int.parse(_dateFormat.format(startDate)),
|
startdate: int.parse(_dateFormat.format(startDate)),
|
||||||
enddate: int.parse(_dateFormat.format(endDate)),
|
enddate: int.parse(_dateFormat.format(endDate)),
|
||||||
onUpdate: (data) {
|
renew: renew,
|
||||||
if (!completer.isCompleted) completer.complete(data);
|
onUpdate: (data) => latest = data,
|
||||||
},
|
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
if (!completer.isCompleted) completer.completeError(e);
|
capturedError = e;
|
||||||
|
onError?.call(e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return completer.future;
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getWeek');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GetRoomsResponse> getRooms() {
|
Future<GetRoomsResponse> getRooms({
|
||||||
final completer = Completer<GetRoomsResponse>();
|
void Function(Object)? onError,
|
||||||
GetRoomsCache(onUpdate: (data) {
|
bool renew = false,
|
||||||
if (!completer.isCompleted) completer.complete(data);
|
}) async {
|
||||||
});
|
GetRoomsResponse? latest;
|
||||||
return completer.future;
|
Object? capturedError;
|
||||||
|
final cache = GetRoomsCache(
|
||||||
|
renew: renew,
|
||||||
|
onUpdate: (data) => latest = data,
|
||||||
|
onError: (e) {
|
||||||
|
capturedError = e;
|
||||||
|
onError?.call(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getRooms');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GetSubjectsResponse> getSubjects() {
|
Future<GetSubjectsResponse> getSubjects({
|
||||||
final completer = Completer<GetSubjectsResponse>();
|
void Function(Object)? onError,
|
||||||
GetSubjectsCache(onUpdate: (data) {
|
bool renew = false,
|
||||||
if (!completer.isCompleted) completer.complete(data);
|
}) async {
|
||||||
});
|
GetSubjectsResponse? latest;
|
||||||
return completer.future;
|
Object? capturedError;
|
||||||
|
final cache = GetSubjectsCache(
|
||||||
|
renew: renew,
|
||||||
|
onUpdate: (data) => latest = data,
|
||||||
|
onError: (e) {
|
||||||
|
capturedError = e;
|
||||||
|
onError?.call(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getSubjects');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GetHolidaysResponse> getSchoolHolidays() {
|
Future<GetHolidaysResponse> getSchoolHolidays({
|
||||||
final completer = Completer<GetHolidaysResponse>();
|
void Function(Object)? onError,
|
||||||
GetHolidaysCache(onUpdate: (data) {
|
bool renew = false,
|
||||||
if (!completer.isCompleted) completer.complete(data);
|
}) async {
|
||||||
});
|
GetHolidaysResponse? latest;
|
||||||
return completer.future;
|
Object? capturedError;
|
||||||
|
final cache = GetHolidaysCache(
|
||||||
|
renew: renew,
|
||||||
|
onUpdate: (data) => latest = data,
|
||||||
|
onError: (e) {
|
||||||
|
capturedError = e;
|
||||||
|
onError?.call(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getSchoolHolidays');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GetCustomTimetableEventResponse> getCustomEvents({bool renew = false}) {
|
Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) async {
|
||||||
final completer = Completer<GetCustomTimetableEventResponse>();
|
GetTimegridUnitsResponse? latest;
|
||||||
GetCustomTimetableEventCache(
|
Object? capturedError;
|
||||||
|
final cache = GetTimegridUnitsCache(
|
||||||
|
renew: renew,
|
||||||
|
onUpdate: (data) => latest = data,
|
||||||
|
);
|
||||||
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getTimegrid');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GetCustomTimetableEventResponse> getCustomEvents({
|
||||||
|
bool renew = false,
|
||||||
|
void Function(Object)? onError,
|
||||||
|
}) async {
|
||||||
|
GetCustomTimetableEventResponse? latest;
|
||||||
|
Object? capturedError;
|
||||||
|
final cache = GetCustomTimetableEventCache(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) {
|
onUpdate: (data) => latest = data,
|
||||||
if (!completer.isCompleted) completer.complete(data);
|
onError: (e) {
|
||||||
|
capturedError = e;
|
||||||
|
onError?.call(e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return completer.future;
|
await cache.ready;
|
||||||
|
if (latest != null) return latest!;
|
||||||
|
throw capturedError ?? Exception('No data and no error from getCustomEvents');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addCustomEvent(CustomTimetableEvent event) =>
|
Future<void> addCustomEvent(CustomTimetableEvent event) =>
|
||||||
|
|||||||
@@ -15,17 +15,28 @@ import 'custom_event_colors.dart';
|
|||||||
|
|
||||||
class CustomEventEditDialog extends StatefulWidget {
|
class CustomEventEditDialog extends StatefulWidget {
|
||||||
final CustomTimetableEvent? existingEvent;
|
final CustomTimetableEvent? existingEvent;
|
||||||
|
final DateTime? initialStart;
|
||||||
|
final DateTime? initialEnd;
|
||||||
|
|
||||||
const CustomEventEditDialog({this.existingEvent, super.key});
|
const CustomEventEditDialog({
|
||||||
|
this.existingEvent,
|
||||||
|
this.initialStart,
|
||||||
|
this.initialEnd,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CustomEventEditDialog> createState() => _CustomEventEditDialogState();
|
State<CustomEventEditDialog> createState() => _CustomEventEditDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||||
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
|
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
|
||||||
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0);
|
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ??
|
||||||
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
|
widget.initialStart?.toTimeOfDay() ??
|
||||||
|
const TimeOfDay(hour: 8, minute: 0);
|
||||||
|
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ??
|
||||||
|
widget.initialEnd?.toTimeOfDay() ??
|
||||||
|
const TimeOfDay(hour: 9, minute: 30);
|
||||||
late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title);
|
late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title);
|
||||||
late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description);
|
late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description);
|
||||||
late String _rrule = widget.existingEvent?.rrule ?? '';
|
late String _rrule = widget.existingEvent?.rrule ?? '';
|
||||||
@@ -167,13 +178,20 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
RRuleGenerator(
|
RRuleGenerator(
|
||||||
config: RRuleGeneratorConfig(
|
config: RRuleGeneratorConfig(
|
||||||
headerEnabled: true,
|
selectDayStyle: RRuleSelectDayStyle(
|
||||||
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
|
dayStyle: BoxDecoration(
|
||||||
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
|
shape: BoxShape.circle,
|
||||||
weekdayColor: Colors.black,
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
|
),
|
||||||
|
dayTextStyle: const TextStyle(color: Colors.black),
|
||||||
|
selectedDayStyle: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
initialRRule: _rrule,
|
initialRRule: _rrule,
|
||||||
textDelegate: const GermanRRuleTextDelegate(),
|
locale: RRuleLocale.de_DE,
|
||||||
onChange: (newValue) {
|
onChange: (newValue) {
|
||||||
log('Rule: $newValue');
|
log('Rule: $newValue');
|
||||||
setState(() => _rrule = newValue);
|
setState(() => _rrule = newValue);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const double kCalendarStartHour = 7.5;
|
||||||
|
const double kCalendarEndHour = 17.25;
|
||||||
|
const Duration kCalendarTimeInterval = Duration(minutes: 30);
|
||||||
|
const double kCalendarViewHeaderHeight = 60;
|
||||||
|
|
||||||
|
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
|
||||||
|
/// than compressing further.
|
||||||
|
const double kCalendarMinPxPerHour = 56;
|
||||||
@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'lesson_status.dart';
|
import 'lesson_status.dart';
|
||||||
|
|
||||||
class LessonColor {
|
class LessonColor {
|
||||||
|
static const Color regular = Color.fromARGB(255, 153, 51, 51);
|
||||||
|
static const Color ongoing = Color.fromARGB(255, 200, 51, 51);
|
||||||
static const Color cancelled = Color(0xff000000);
|
static const Color cancelled = Color(0xff000000);
|
||||||
static const Color irregular = Color(0xff8F19B3);
|
static const Color irregular = Color(0xff8F19B3);
|
||||||
static const Color teacherChanged = Color(0xFF29639B);
|
static const Color teacherChanged = Color(0xFF29639B);
|
||||||
static const Color parseFallback = Color(0xff404040);
|
static const Color parseFallback = Color(0xff404040);
|
||||||
|
|
||||||
static Color forStatus(LessonStatus status, ColorScheme scheme) {
|
static Color forStatus(LessonStatus status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case LessonStatus.cancelled:
|
case LessonStatus.cancelled:
|
||||||
return cancelled;
|
return cancelled;
|
||||||
@@ -18,14 +20,9 @@ class LessonColor {
|
|||||||
return teacherChanged;
|
return teacherChanged;
|
||||||
case LessonStatus.past:
|
case LessonStatus.past:
|
||||||
case LessonStatus.regular:
|
case LessonStatus.regular:
|
||||||
return scheme.primary;
|
return regular;
|
||||||
case LessonStatus.ongoing:
|
case LessonStatus.ongoing:
|
||||||
return Color.from(
|
return ongoing;
|
||||||
alpha: scheme.primary.a,
|
|
||||||
red: 200 / 255,
|
|
||||||
green: scheme.primary.g,
|
|
||||||
blue: scheme.primary.b,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/webuntis/queries/getTimegridUnits/getTimegridUnitsResponse.dart';
|
||||||
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
|
|
||||||
|
class LessonPeriod {
|
||||||
|
final String name;
|
||||||
|
final TimeOfDay start;
|
||||||
|
final TimeOfDay end;
|
||||||
|
final bool isBreak;
|
||||||
|
|
||||||
|
const LessonPeriod({
|
||||||
|
required this.name,
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
this.isBreak = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Duration get duration => Duration(
|
||||||
|
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
|
||||||
|
);
|
||||||
|
|
||||||
|
int get _startMinutes => start.hour * 60 + start.minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LessonPeriodSchedule {
|
||||||
|
final List<LessonPeriod> periods;
|
||||||
|
|
||||||
|
const LessonPeriodSchedule(this.periods);
|
||||||
|
|
||||||
|
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
|
||||||
|
final canonical = response.result.firstWhere(
|
||||||
|
(d) => d.day == 1,
|
||||||
|
orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []),
|
||||||
|
);
|
||||||
|
if (canonical.timeUnits.isEmpty) return null;
|
||||||
|
|
||||||
|
final periods = canonical.timeUnits
|
||||||
|
.map((u) => LessonPeriod(
|
||||||
|
name: u.name,
|
||||||
|
start: _fromHHMM(u.startTime),
|
||||||
|
end: _fromHHMM(u.endTime),
|
||||||
|
))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
|
||||||
|
|
||||||
|
return LessonPeriodSchedule(periods);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([
|
||||||
|
LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)),
|
||||||
|
LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)),
|
||||||
|
LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)),
|
||||||
|
LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)),
|
||||||
|
LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)),
|
||||||
|
LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)),
|
||||||
|
LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)),
|
||||||
|
LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)),
|
||||||
|
LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)),
|
||||||
|
LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)),
|
||||||
|
LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)),
|
||||||
|
LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
static LessonPeriodSchedule fromState(TimetableState state) {
|
||||||
|
final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null;
|
||||||
|
return (fromApi ?? fallback()).withSyntheticBreaks();
|
||||||
|
}
|
||||||
|
|
||||||
|
LessonPeriodSchedule withSyntheticBreaks() {
|
||||||
|
final result = <LessonPeriod>[];
|
||||||
|
for (var i = 0; i < periods.length; i++) {
|
||||||
|
final current = periods[i];
|
||||||
|
result.add(current);
|
||||||
|
if (i + 1 >= periods.length) continue;
|
||||||
|
final next = periods[i + 1];
|
||||||
|
final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute);
|
||||||
|
if (gapMinutes >= 10) {
|
||||||
|
result.add(LessonPeriod(
|
||||||
|
name: 'Pause',
|
||||||
|
start: current.end,
|
||||||
|
end: next.start,
|
||||||
|
isBreak: true,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LessonPeriodSchedule(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay(
|
||||||
|
hour: hhmm ~/ 100,
|
||||||
|
minute: hhmm % 100,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||||
@@ -20,7 +19,6 @@ class TimetableAppointmentFactory {
|
|||||||
final GetRoomsResponse rooms;
|
final GetRoomsResponse rooms;
|
||||||
final GetSubjectsResponse subjects;
|
final GetSubjectsResponse subjects;
|
||||||
final TimetableSettings settings;
|
final TimetableSettings settings;
|
||||||
final ColorScheme colorScheme;
|
|
||||||
final DateTime now;
|
final DateTime now;
|
||||||
|
|
||||||
TimetableAppointmentFactory({
|
TimetableAppointmentFactory({
|
||||||
@@ -29,7 +27,6 @@ class TimetableAppointmentFactory {
|
|||||||
required this.rooms,
|
required this.rooms,
|
||||||
required this.subjects,
|
required this.subjects,
|
||||||
required this.settings,
|
required this.settings,
|
||||||
required this.colorScheme,
|
|
||||||
required this.now,
|
required this.now,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +51,7 @@ class TimetableAppointmentFactory {
|
|||||||
subject: _subjectName(lesson),
|
subject: _subjectName(lesson),
|
||||||
location: _locationLabel(lesson),
|
location: _locationLabel(lesson),
|
||||||
notes: lesson.activityType,
|
notes: lesson.activityType,
|
||||||
color: LessonColor.forStatus(status, colorScheme),
|
color: LessonColor.forStatus(status),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return Appointment(
|
return Appointment(
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
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 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
@@ -12,12 +10,11 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
|||||||
import 'custom_events/custom_event_edit_dialog.dart';
|
import 'custom_events/custom_event_edit_dialog.dart';
|
||||||
import 'custom_events/custom_events_view.dart';
|
import 'custom_events/custom_events_view.dart';
|
||||||
import 'data/arbitrary_appointment.dart';
|
import 'data/arbitrary_appointment.dart';
|
||||||
|
import 'data/lesson_period_schedule.dart';
|
||||||
import 'data/timetable_appointment_factory.dart';
|
import 'data/timetable_appointment_factory.dart';
|
||||||
import 'details/appointment_details_dispatcher.dart';
|
import 'details/appointment_details_dispatcher.dart';
|
||||||
import 'widgets/appointment_tile.dart';
|
import 'widgets/custom_workweek_calendar.dart';
|
||||||
import 'widgets/lesson_appointment_source.dart';
|
|
||||||
import 'widgets/special_regions_builder.dart';
|
import 'widgets/special_regions_builder.dart';
|
||||||
import 'widgets/time_region_tile.dart';
|
|
||||||
|
|
||||||
enum _CalendarAction { addEvent, viewEvents }
|
enum _CalendarAction { addEvent, viewEvents }
|
||||||
|
|
||||||
@@ -29,32 +26,15 @@ class Timetable extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TimetableState extends State<Timetable> {
|
class _TimetableState extends State<Timetable> {
|
||||||
final CalendarController _controller = CalendarController();
|
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey = GlobalKey<CustomWorkWeekCalendarState>();
|
||||||
late Timer _highlightTicker;
|
|
||||||
|
|
||||||
LessonAppointmentSource? _cachedSource;
|
List<Appointment>? _cachedAppointments;
|
||||||
int? _lastDataVersion;
|
int? _lastDataVersion;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller.displayDate = _initialDisplayDate();
|
|
||||||
|
|
||||||
_highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
||||||
if (mounted) setState(() => _cachedSource = null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_highlightTicker.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||||
|
|
||||||
void _jumpToToday() {
|
void _jumpToToday() {
|
||||||
_controller.displayDate = _initialDisplayDate();
|
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAction(_CalendarAction action) {
|
void _onAction(_CalendarAction action) {
|
||||||
@@ -70,24 +50,27 @@ class _TimetableState extends State<Timetable> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LessonAppointmentSource _appointmentSource(TimetableState state) {
|
List<Appointment> _appointments(TimetableState state) {
|
||||||
if (_cachedSource != null && _lastDataVersion == state.dataVersion) {
|
if (_cachedAppointments != null && _lastDataVersion == state.dataVersion) {
|
||||||
return _cachedSource!;
|
return _cachedAppointments!;
|
||||||
}
|
}
|
||||||
_lastDataVersion = state.dataVersion;
|
_lastDataVersion = state.dataVersion;
|
||||||
|
|
||||||
final settings = context.read<SettingsCubit>();
|
final settings = context.read<SettingsCubit>();
|
||||||
final appointments = TimetableAppointmentFactory(
|
return _cachedAppointments = TimetableAppointmentFactory(
|
||||||
lessons: state.getAllKnownLessons().toList(),
|
lessons: state.getAllKnownLessons().toList(),
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
customEvents: state.customEvents?.events ?? const [],
|
||||||
rooms: state.rooms!,
|
rooms: state.rooms!,
|
||||||
subjects: state.subjects!,
|
subjects: state.subjects!,
|
||||||
settings: settings.val().timetableSettings,
|
settings: settings.val().timetableSettings,
|
||||||
colorScheme: Theme.of(context).colorScheme,
|
|
||||||
now: DateTime.now(),
|
now: DateTime.now(),
|
||||||
).build();
|
).build();
|
||||||
|
}
|
||||||
|
|
||||||
return _cachedSource = LessonAppointmentSource(appointments);
|
bool _isCrossedOut(Appointment appointment) {
|
||||||
|
final id = appointment.id;
|
||||||
|
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -126,54 +109,35 @@ class _TimetableState extends State<Timetable> {
|
|||||||
Widget _calendar(TimetableState state, TimetableBloc bloc) {
|
Widget _calendar(TimetableState state, TimetableBloc bloc) {
|
||||||
if (!state.hasReferenceData) return const SizedBox.shrink();
|
if (!state.hasReferenceData) return const SizedBox.shrink();
|
||||||
|
|
||||||
return SfCalendar(
|
final schedule = LessonPeriodSchedule.fromState(state);
|
||||||
timeZone: 'W. Europe Standard Time',
|
final appointments = _appointments(state);
|
||||||
view: CalendarView.workWeek,
|
final regions = SpecialRegionsBuilder(
|
||||||
dataSource: _appointmentSource(state),
|
|
||||||
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
|
|
||||||
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
|
|
||||||
controller: _controller,
|
|
||||||
onViewChanged: (details) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
bloc.changeWeek(details.visibleDates.first, details.visibleDates.last);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onTap: (tap) {
|
|
||||||
if (tap.appointments == null || tap.appointments!.isEmpty) return;
|
|
||||||
AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first);
|
|
||||||
},
|
|
||||||
firstDayOfWeek: DateTime.monday,
|
|
||||||
specialRegions: SpecialRegionsBuilder(
|
|
||||||
holidays: state.schoolHolidays!,
|
holidays: state.schoolHolidays!,
|
||||||
|
schedule: schedule,
|
||||||
colorScheme: Theme.of(context).colorScheme,
|
colorScheme: Theme.of(context).colorScheme,
|
||||||
disabledColor: Theme.of(context).disabledColor,
|
disabledColor: Theme.of(context).disabledColor,
|
||||||
).build(),
|
).build();
|
||||||
timeSlotViewSettings: const TimeSlotViewSettings(
|
|
||||||
startHour: 7.5,
|
return CustomWorkWeekCalendar(
|
||||||
endHour: 16.5,
|
key: _calendarKey,
|
||||||
timeInterval: Duration(minutes: 30),
|
schedule: schedule,
|
||||||
timeFormat: 'HH:mm',
|
appointments: appointments,
|
||||||
dayFormat: 'EE',
|
timeRegions: regions,
|
||||||
timeIntervalHeight: 40,
|
initialDate: _initialDisplayDate(),
|
||||||
),
|
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
|
||||||
timeRegionBuilder: (_, details) => TimeRegionTile(details: details),
|
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
|
||||||
appointmentBuilder: (_, details) => AppointmentTile(
|
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt),
|
||||||
details: details,
|
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
|
||||||
crossedOut: _isCrossedOut(details),
|
isCrossedOut: _isCrossedOut,
|
||||||
),
|
onCreateEvent: _onCreateEventAt,
|
||||||
headerHeight: 0,
|
|
||||||
selectionDecoration: const BoxDecoration(),
|
|
||||||
allowAppointmentResize: false,
|
|
||||||
allowDragAndDrop: false,
|
|
||||||
allowViewNavigation: false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isCrossedOut(CalendarAppointmentDetails details) {
|
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||||
final appointment = details.appointments.first;
|
showDialog(
|
||||||
final id = appointment.id;
|
context: context,
|
||||||
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||||
return false;
|
barrierDismissible: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,27 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
import 'cross_painter.dart';
|
import 'cross_painter.dart';
|
||||||
|
|
||||||
class AppointmentTile extends StatelessWidget {
|
class AppointmentTile extends StatelessWidget {
|
||||||
final CalendarAppointmentDetails details;
|
final Appointment appointment;
|
||||||
final bool crossedOut;
|
final bool crossedOut;
|
||||||
|
|
||||||
const AppointmentTile({super.key, required this.details, this.crossedOut = false});
|
const AppointmentTile({super.key, required this.appointment, this.crossedOut = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Appointment meeting = details.appointments.first;
|
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||||
final isPast = meeting.endTime.isBefore(DateTime.now());
|
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||||
final color = meeting.color.withAlpha(isPast ? 100 : 255);
|
|
||||||
|
|
||||||
return Stack(
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Positioned.fill(
|
||||||
padding: const EdgeInsets.all(3),
|
child: Container(
|
||||||
height: details.bounds.height,
|
padding: const EdgeInsets.all(4),
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.rectangle,
|
shape: BoxShape.rectangle,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -33,7 +34,7 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
FittedBox(
|
FittedBox(
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
meeting.subject,
|
appointment.subject,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
softWrap: false,
|
softWrap: false,
|
||||||
@@ -42,7 +43,7 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
FittedBox(
|
FittedBox(
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
child: Text(
|
child: Text(
|
||||||
meeting.location?.isNotEmpty == true ? meeting.location! : ' ',
|
appointment.location?.isNotEmpty == true ? appointment.location! : ' ',
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
@@ -52,17 +53,19 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (crossedOut)
|
if (crossedOut)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
borderRadius: const BorderRadius.all(Radius.circular(7)),
|
||||||
),
|
),
|
||||||
child: CustomPaint(painter: CrossPainter()),
|
child: CustomPaint(painter: CrossPainter()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:rrule/rrule.dart';
|
||||||
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
|
import '../data/calendar_layout.dart';
|
||||||
|
import '../data/lesson_period_schedule.dart';
|
||||||
|
import 'appointment_tile.dart';
|
||||||
|
import 'time_region_tile.dart';
|
||||||
|
|
||||||
|
class CustomWorkWeekCalendar extends StatefulWidget {
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final List<TimeRegion> timeRegions;
|
||||||
|
final DateTime initialDate;
|
||||||
|
final DateTime minDate;
|
||||||
|
final DateTime maxDate;
|
||||||
|
final void Function(Appointment appointment) onAppointmentTap;
|
||||||
|
final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged;
|
||||||
|
final bool Function(Appointment appointment) isCrossedOut;
|
||||||
|
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||||
|
|
||||||
|
const CustomWorkWeekCalendar({
|
||||||
|
super.key,
|
||||||
|
required this.schedule,
|
||||||
|
required this.appointments,
|
||||||
|
required this.timeRegions,
|
||||||
|
required this.initialDate,
|
||||||
|
required this.minDate,
|
||||||
|
required this.maxDate,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
required this.onWeekChanged,
|
||||||
|
required this.isCrossedOut,
|
||||||
|
this.onCreateEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomWorkWeekCalendar> createState() => CustomWorkWeekCalendarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||||
|
static const double _rulerWidth = 50;
|
||||||
|
|
||||||
|
late PageController _pageController;
|
||||||
|
late int _currentWeekIndex;
|
||||||
|
late DateTime _firstMonday;
|
||||||
|
late int _totalWeeks;
|
||||||
|
late Timer _ticker;
|
||||||
|
late ValueNotifier<DateTime> _nowNotifier;
|
||||||
|
DateTime _today = _dateOnly(DateTime.now());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_firstMonday = _mondayOf(widget.minDate);
|
||||||
|
final lastMonday = _mondayOf(widget.maxDate);
|
||||||
|
_totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1;
|
||||||
|
_currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
|
||||||
|
_pageController = PageController(initialPage: _currentWeekIndex);
|
||||||
|
_nowNotifier = ValueNotifier<DateTime>(DateTime.now());
|
||||||
|
|
||||||
|
_ticker = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
_nowNotifier.value = now;
|
||||||
|
final newToday = _dateOnly(now);
|
||||||
|
if (newToday != _today) setState(() => _today = newToday);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
_ticker.cancel();
|
||||||
|
_nowNotifier.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||||
|
|
||||||
|
void jumpToDate(DateTime date) {
|
||||||
|
final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7;
|
||||||
|
if (target < 0 || target >= _totalWeeks) return;
|
||||||
|
_pageController.animateToPage(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 380),
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _mondayOf(DateTime d) {
|
||||||
|
final monday = d.subtract(Duration(days: d.weekday - 1));
|
||||||
|
return DateTime(monday.year, monday.month, monday.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: kCalendarViewHeaderHeight,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, animation) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, -0.08),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _DayHeaderStrip(
|
||||||
|
key: ValueKey(visibleWeekStart),
|
||||||
|
weekStart: visibleWeekStart,
|
||||||
|
today: _today,
|
||||||
|
rulerWidth: _rulerWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final hours = kCalendarEndHour - kCalendarStartHour;
|
||||||
|
final fitPxPerHour = constraints.maxHeight / hours;
|
||||||
|
final pxPerHour =
|
||||||
|
fitPxPerHour < kCalendarMinPxPerHour ? kCalendarMinPxPerHour : fitPxPerHour;
|
||||||
|
final gridHeight = pxPerHour * hours;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
child: SizedBox(
|
||||||
|
height: gridHeight,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: _totalWeeks,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
setState(() => _currentWeekIndex = index);
|
||||||
|
final weekStart = _firstMonday.add(Duration(days: index * 7));
|
||||||
|
widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4)));
|
||||||
|
},
|
||||||
|
itemBuilder: (_, weekIndex) {
|
||||||
|
final weekStart = _firstMonday.add(Duration(days: weekIndex * 7));
|
||||||
|
return _WeekGrid(
|
||||||
|
weekStart: weekStart,
|
||||||
|
schedule: widget.schedule,
|
||||||
|
appointments: widget.appointments,
|
||||||
|
timeRegions: widget.timeRegions,
|
||||||
|
onAppointmentTap: widget.onAppointmentTap,
|
||||||
|
isCrossedOut: widget.isCrossedOut,
|
||||||
|
onCreateEvent: widget.onCreateEvent,
|
||||||
|
today: _today,
|
||||||
|
nowNotifier: _nowNotifier,
|
||||||
|
rulerWidth: _rulerWidth,
|
||||||
|
pxPerHour: pxPerHour,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayHeaderStrip extends StatelessWidget {
|
||||||
|
final DateTime weekStart;
|
||||||
|
final DateTime today;
|
||||||
|
final double rulerWidth;
|
||||||
|
|
||||||
|
const _DayHeaderStrip({
|
||||||
|
super.key,
|
||||||
|
required this.weekStart,
|
||||||
|
required this.today,
|
||||||
|
required this.rulerWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: rulerWidth),
|
||||||
|
for (var d = 0; d < 5; d++)
|
||||||
|
Expanded(
|
||||||
|
child: _DayHeaderCell(
|
||||||
|
date: weekStart.add(Duration(days: d)),
|
||||||
|
today: today,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayHeaderCell extends StatelessWidget {
|
||||||
|
final DateTime date;
|
||||||
|
final DateTime today;
|
||||||
|
|
||||||
|
const _DayHeaderCell({required this.date, required this.today});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isToday = _isSameDay(date, today);
|
||||||
|
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
|
||||||
|
|
||||||
|
final accent = theme.colorScheme.primary;
|
||||||
|
final onAccent = theme.colorScheme.onPrimary;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
dayName,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: isToday ? accent : theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isToday ? accent : Colors.transparent,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'${date.day}',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: isToday ? onAccent : theme.colorScheme.onSurface,
|
||||||
|
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeekGrid extends StatelessWidget {
|
||||||
|
final DateTime weekStart;
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final List<TimeRegion> timeRegions;
|
||||||
|
final void Function(Appointment) onAppointmentTap;
|
||||||
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||||
|
final DateTime today;
|
||||||
|
final ValueListenable<DateTime> nowNotifier;
|
||||||
|
final double rulerWidth;
|
||||||
|
final double pxPerHour;
|
||||||
|
|
||||||
|
const _WeekGrid({
|
||||||
|
required this.weekStart,
|
||||||
|
required this.schedule,
|
||||||
|
required this.appointments,
|
||||||
|
required this.timeRegions,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
required this.isCrossedOut,
|
||||||
|
required this.onCreateEvent,
|
||||||
|
required this.today,
|
||||||
|
required this.nowNotifier,
|
||||||
|
required this.rulerWidth,
|
||||||
|
required this.pxPerHour,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final perDay = _expandAppointmentsForWeek(appointments, weekStart);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_PeriodRuler(
|
||||||
|
schedule: schedule,
|
||||||
|
pxPerHour: pxPerHour,
|
||||||
|
width: rulerWidth,
|
||||||
|
),
|
||||||
|
for (var d = 0; d < 5; d++)
|
||||||
|
Expanded(
|
||||||
|
child: _DayColumn(
|
||||||
|
date: weekStart.add(Duration(days: d)),
|
||||||
|
schedule: schedule,
|
||||||
|
appointments: perDay[d],
|
||||||
|
timeRegions: timeRegions,
|
||||||
|
pxPerHour: pxPerHour,
|
||||||
|
today: today,
|
||||||
|
nowNotifier: nowNotifier,
|
||||||
|
onAppointmentTap: onAppointmentTap,
|
||||||
|
isCrossedOut: isCrossedOut,
|
||||||
|
onCreateEvent: onCreateEvent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeriodRuler extends StatelessWidget {
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
|
final double pxPerHour;
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
const _PeriodRuler({
|
||||||
|
required this.schedule,
|
||||||
|
required this.pxPerHour,
|
||||||
|
required this.width,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _y(TimeOfDay t) =>
|
||||||
|
(t.hour + t.minute / 60 - kCalendarStartHour) * pxPerHour;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
for (final period in schedule.periods)
|
||||||
|
Positioned(
|
||||||
|
top: _y(period.start),
|
||||||
|
height: (_y(period.end) - _y(period.start)).clamp(0, double.infinity),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: _PeriodLabel(period: period, theme: theme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeriodLabel extends StatelessWidget {
|
||||||
|
final LessonPeriod period;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
const _PeriodLabel({required this.period, required this.theme});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dividerColor = theme.dividerColor.withAlpha(110);
|
||||||
|
final secondaryTextColor = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
if (period.isBreak) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: dividerColor, width: 0.5),
|
||||||
|
bottom: BorderSide(color: dividerColor, width: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final timeStyle = theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: secondaryTextColor,
|
||||||
|
height: 1.0,
|
||||||
|
fontSize: 10,
|
||||||
|
);
|
||||||
|
const tightTextHeight = TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final showTimes = constraints.maxHeight >= 38;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: dividerColor, width: 0.5)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
if (showTimes)
|
||||||
|
Positioned(
|
||||||
|
top: 3,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Text(
|
||||||
|
_format(period.start),
|
||||||
|
style: timeStyle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textHeightBehavior: tightTextHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${period.name}.',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textHeightBehavior: tightTextHeight,
|
||||||
|
),
|
||||||
|
if (showTimes)
|
||||||
|
Positioned(
|
||||||
|
bottom: 3,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Text(
|
||||||
|
_format(period.end),
|
||||||
|
style: timeStyle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textHeightBehavior: tightTextHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _format(TimeOfDay t) =>
|
||||||
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DayColumn extends StatelessWidget {
|
||||||
|
final DateTime date;
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final List<TimeRegion> timeRegions;
|
||||||
|
final double pxPerHour;
|
||||||
|
final DateTime today;
|
||||||
|
final ValueListenable<DateTime> nowNotifier;
|
||||||
|
final void Function(Appointment) onAppointmentTap;
|
||||||
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||||
|
|
||||||
|
const _DayColumn({
|
||||||
|
required this.date,
|
||||||
|
required this.schedule,
|
||||||
|
required this.appointments,
|
||||||
|
required this.timeRegions,
|
||||||
|
required this.pxPerHour,
|
||||||
|
required this.today,
|
||||||
|
required this.nowNotifier,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
required this.isCrossedOut,
|
||||||
|
required this.onCreateEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
double _y(int hour, int minute) =>
|
||||||
|
(hour + minute / 60 - kCalendarStartHour) * pxPerHour;
|
||||||
|
|
||||||
|
double _yFromDate(DateTime t) => _y(t.hour, t.minute);
|
||||||
|
|
||||||
|
/// Snaps an appointment edge to the nearest period boundary if the gap is small,
|
||||||
|
/// so small inter-period transitions (Wechselzeiten) appear as part of the lesson visually.
|
||||||
|
double _yForAppointmentEdge(DateTime t, {required bool isStart}) {
|
||||||
|
final tMin = t.hour * 60 + t.minute;
|
||||||
|
for (final period in schedule.periods) {
|
||||||
|
if (period.isBreak) continue;
|
||||||
|
final pStart = period.start.hour * 60 + period.start.minute;
|
||||||
|
final pEnd = period.end.hour * 60 + period.end.minute;
|
||||||
|
if (isStart) {
|
||||||
|
final delta = tMin - pStart;
|
||||||
|
if (delta >= 0 && delta < 5) {
|
||||||
|
return _y(period.start.hour, period.start.minute);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final delta = pEnd - tMin;
|
||||||
|
if (delta >= 0 && delta < 5) {
|
||||||
|
// Snap to the next non-break period's start when the gap is short
|
||||||
|
// (Wechselzeit). Skips into a break never extends the lesson.
|
||||||
|
final idx = schedule.periods.indexOf(period);
|
||||||
|
if (idx + 1 < schedule.periods.length) {
|
||||||
|
final next = schedule.periods[idx + 1];
|
||||||
|
if (!next.isBreak) {
|
||||||
|
final nextStart = next.start.hour * 60 + next.start.minute;
|
||||||
|
if (nextStart - pEnd < 10) {
|
||||||
|
return _y(next.start.hour, next.start.minute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _yFromDate(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the lesson period (non-break) that the given y-offset falls into,
|
||||||
|
/// or the next upcoming non-break period if y falls inside a break or before
|
||||||
|
/// the first period. Returns null if y is past the last period of the day.
|
||||||
|
LessonPeriod? _periodAt(double y) {
|
||||||
|
final hoursDecimal = y / pxPerHour + kCalendarStartHour;
|
||||||
|
final tappedMinutes = (hoursDecimal * 60).round();
|
||||||
|
|
||||||
|
LessonPeriod? upcoming;
|
||||||
|
for (final p in schedule.periods) {
|
||||||
|
if (p.isBreak) continue;
|
||||||
|
final pStart = p.start.hour * 60 + p.start.minute;
|
||||||
|
final pEnd = p.end.hour * 60 + p.end.minute;
|
||||||
|
if (tappedMinutes >= pStart && tappedMinutes < pEnd) return p;
|
||||||
|
if (tappedMinutes < pStart) {
|
||||||
|
upcoming = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return upcoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _overlapsExistingAppointment(DateTime start, DateTime end, List<Appointment> dayAppts) {
|
||||||
|
for (final a in dayAppts) {
|
||||||
|
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
|
||||||
|
if (onCreateEvent == null) return;
|
||||||
|
final period = _periodAt(details.localPosition.dy);
|
||||||
|
if (period == null) return;
|
||||||
|
|
||||||
|
final start = DateTime(date.year, date.month, date.day, period.start.hour, period.start.minute);
|
||||||
|
final end = DateTime(date.year, date.month, date.day, period.end.hour, period.end.minute);
|
||||||
|
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
|
||||||
|
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
onCreateEvent!(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final dayAppointments = appointments;
|
||||||
|
final dayRegions = _expandRegionsForDay(timeRegions, date);
|
||||||
|
final isToday = _isSameDay(date, today);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
|
||||||
|
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
for (final period in schedule.periods)
|
||||||
|
Positioned(
|
||||||
|
top: _y(period.start.hour, period.start.minute),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
height: 0.5,
|
||||||
|
color: theme.dividerColor.withAlpha(60),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final region in dayRegions)
|
||||||
|
Positioned(
|
||||||
|
top: _yFromDate(region.start),
|
||||||
|
height:
|
||||||
|
(_yFromDate(region.end) - _yFromDate(region.start)).clamp(0, double.infinity),
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: TimeRegionTile(region: region.region),
|
||||||
|
),
|
||||||
|
for (final apt in dayAppointments)
|
||||||
|
Positioned(
|
||||||
|
top: _yForAppointmentEdge(apt.startTime, isStart: true),
|
||||||
|
height: (_yForAppointmentEdge(apt.endTime, isStart: false) -
|
||||||
|
_yForAppointmentEdge(apt.startTime, isStart: true))
|
||||||
|
.clamp(0, double.infinity),
|
||||||
|
left: 1,
|
||||||
|
right: 1,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => onAppointmentTap(apt),
|
||||||
|
child: AppointmentTile(
|
||||||
|
appointment: apt,
|
||||||
|
crossedOut: isCrossedOut(apt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isToday)
|
||||||
|
ValueListenableBuilder<DateTime>(
|
||||||
|
valueListenable: nowNotifier,
|
||||||
|
builder: (_, now, child) =>
|
||||||
|
_CurrentTimeMarker(now: now, pxPerHour: pxPerHour, theme: theme),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CurrentTimeMarker extends StatelessWidget {
|
||||||
|
final DateTime now;
|
||||||
|
final double pxPerHour;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
const _CurrentTimeMarker({
|
||||||
|
required this.now,
|
||||||
|
required this.pxPerHour,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final y = (now.hour + now.minute / 60 + now.second / 3600 - kCalendarStartHour) * pxPerHour;
|
||||||
|
final maxY = (kCalendarEndHour - kCalendarStartHour) * pxPerHour;
|
||||||
|
if (y < 0 || y > maxY) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
top: y - 1,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 2,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -3,
|
||||||
|
left: -4,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoundRegion {
|
||||||
|
final TimeRegion region;
|
||||||
|
final DateTime start;
|
||||||
|
final DateTime end;
|
||||||
|
|
||||||
|
_BoundRegion({required this.region, required this.start, required this.end});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_BoundRegion> _expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
||||||
|
final result = <_BoundRegion>[];
|
||||||
|
final dayStart = DateTime(day.year, day.month, day.day);
|
||||||
|
for (final region in regions) {
|
||||||
|
final isRecurringDaily = region.recurrenceRule != null &&
|
||||||
|
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
|
||||||
|
if (isRecurringDaily) {
|
||||||
|
final start = dayStart.add(Duration(
|
||||||
|
hours: region.startTime.hour,
|
||||||
|
minutes: region.startTime.minute,
|
||||||
|
));
|
||||||
|
final end = dayStart.add(Duration(
|
||||||
|
hours: region.endTime.hour,
|
||||||
|
minutes: region.endTime.minute,
|
||||||
|
));
|
||||||
|
result.add(_BoundRegion(region: region, start: start, end: end));
|
||||||
|
} else if (_isSameDay(region.startTime, day)) {
|
||||||
|
result.add(_BoundRegion(
|
||||||
|
region: region,
|
||||||
|
start: region.startTime,
|
||||||
|
end: region.endTime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameDay(DateTime a, DateTime b) =>
|
||||||
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
|
||||||
|
/// Expands the given list of appointments across the visible 5-day work week,
|
||||||
|
/// resolving any RRULE-based recurrences into per-day synthetic instances.
|
||||||
|
/// Returns a list of length 5 (Monday..Friday); each entry holds the
|
||||||
|
/// appointments occurring on that day, with `startTime` and `endTime` shifted
|
||||||
|
/// to the actual occurrence date (preserving time-of-day and duration). The
|
||||||
|
/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps
|
||||||
|
/// still resolve to the correct underlying event.
|
||||||
|
List<List<Appointment>> _expandAppointmentsForWeek(
|
||||||
|
List<Appointment> appointments, DateTime weekStart) {
|
||||||
|
final perDay = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||||
|
final weekEnd = weekStart.add(const Duration(days: 5));
|
||||||
|
final weekStartUtc = weekStart.toUtc();
|
||||||
|
final weekEndUtc = weekEnd.toUtc();
|
||||||
|
|
||||||
|
for (final a in appointments) {
|
||||||
|
final rule = a.recurrenceRule;
|
||||||
|
if (rule == null || rule.isEmpty) {
|
||||||
|
final idx = a.startTime.difference(weekStart).inDays;
|
||||||
|
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final parsed = RecurrenceRule.fromString(rule);
|
||||||
|
final anchorUtc = a.startTime.toUtc();
|
||||||
|
final duration = a.endTime.difference(a.startTime);
|
||||||
|
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
|
||||||
|
if (!occUtc.isBefore(weekEndUtc)) break;
|
||||||
|
if (occUtc.isBefore(weekStartUtc)) continue;
|
||||||
|
final occLocal = occUtc.toLocal();
|
||||||
|
final idx = DateTime(occLocal.year, occLocal.month, occLocal.day)
|
||||||
|
.difference(weekStart)
|
||||||
|
.inDays;
|
||||||
|
if (idx < 0 || idx >= 5) continue;
|
||||||
|
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
||||||
|
a.startTime.hour, a.startTime.minute);
|
||||||
|
perDay[idx].add(Appointment(
|
||||||
|
id: a.id,
|
||||||
|
startTime: newStart,
|
||||||
|
endTime: newStart.add(duration),
|
||||||
|
subject: a.subject,
|
||||||
|
color: a.color,
|
||||||
|
location: a.location,
|
||||||
|
notes: a.notes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Malformed RRULE → behave as non-recurring (anchor day only).
|
||||||
|
final idx = a.startTime.difference(weekStart).inDays;
|
||||||
|
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return perDay;
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|
||||||
|
|
||||||
class LessonAppointmentSource extends CalendarDataSource {
|
|
||||||
LessonAppointmentSource(List<Appointment> source) {
|
|
||||||
appointments = source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,32 +3,38 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
||||||
import '../../../../extensions/dateTime.dart';
|
import '../../../../extensions/dateTime.dart';
|
||||||
|
import '../data/calendar_layout.dart';
|
||||||
|
import '../data/lesson_period_schedule.dart';
|
||||||
import '../data/webuntis_time.dart';
|
import '../data/webuntis_time.dart';
|
||||||
import 'time_region_tile.dart';
|
import 'time_region_tile.dart';
|
||||||
|
|
||||||
class SpecialRegionsBuilder {
|
class SpecialRegionsBuilder {
|
||||||
final GetHolidaysResponse holidays;
|
final GetHolidaysResponse holidays;
|
||||||
|
final LessonPeriodSchedule schedule;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final Color disabledColor;
|
final Color disabledColor;
|
||||||
|
|
||||||
SpecialRegionsBuilder({
|
SpecialRegionsBuilder({
|
||||||
required this.holidays,
|
required this.holidays,
|
||||||
|
required this.schedule,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.disabledColor,
|
required this.disabledColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
List<TimeRegion> build() {
|
List<TimeRegion> build() {
|
||||||
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
|
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
|
||||||
final firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
|
|
||||||
final secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
|
|
||||||
|
|
||||||
final holidayRegions = _buildHolidayRegions().toList();
|
final holidayRegions = _buildHolidayRegions().toList();
|
||||||
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
|
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
|
||||||
|
|
||||||
|
final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) {
|
||||||
|
final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute);
|
||||||
|
return _breakRegion(start, p.duration);
|
||||||
|
}).where((region) => !isInHoliday(region.startTime));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...holidayRegions,
|
...holidayRegions,
|
||||||
if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)),
|
...breakRegions,
|
||||||
if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +42,13 @@ class SpecialRegionsBuilder {
|
|||||||
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
||||||
final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays;
|
final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays;
|
||||||
final days = List<DateTime>.generate(dayCount, (i) => startDay.add(Duration(days: i)));
|
final days = List<DateTime>.generate(dayCount, (i) => startDay.add(Duration(days: i)));
|
||||||
|
final gridStartHour = kCalendarStartHour.floor();
|
||||||
|
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
|
||||||
|
final gridEndHour = kCalendarEndHour.floor();
|
||||||
|
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
|
||||||
return days.map((day) => TimeRegion(
|
return days.map((day) => TimeRegion(
|
||||||
startTime: day.copyWith(hour: 7, minute: 55),
|
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
|
||||||
endTime: day.copyWith(hour: 16, minute: 30),
|
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
||||||
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
||||||
color: disabledColor.withAlpha(50),
|
color: disabledColor.withAlpha(50),
|
||||||
iconData: Icons.holiday_village_outlined,
|
iconData: Icons.holiday_village_outlined,
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ const String kTimeRegionCenterIcon = 'centerIcon';
|
|||||||
const String kTimeRegionHolidayPrefix = 'holiday:';
|
const String kTimeRegionHolidayPrefix = 'holiday:';
|
||||||
|
|
||||||
class TimeRegionTile extends StatelessWidget {
|
class TimeRegionTile extends StatelessWidget {
|
||||||
final TimeRegionDetails details;
|
final TimeRegion region;
|
||||||
|
|
||||||
const TimeRegionTile({super.key, required this.details});
|
const TimeRegionTile({super.key, required this.region});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final text = details.region.text ?? '';
|
final text = region.text ?? '';
|
||||||
final color = details.region.color;
|
final color = region.color;
|
||||||
|
|
||||||
if (text == kTimeRegionCenterIcon) {
|
if (text == kTimeRegionCenterIcon) {
|
||||||
return Container(
|
return Container(
|
||||||
color: color,
|
color: color,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor),
|
child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,6 @@ class TimeRegionTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return const Placeholder();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-27
@@ -1,45 +1,139 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../model/accountData.dart';
|
import '../model/accountData.dart';
|
||||||
import '../model/endpointData.dart';
|
import '../model/endpointData.dart';
|
||||||
|
|
||||||
class UserAvatar extends StatelessWidget {
|
class UserAvatar extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final int size;
|
final int size;
|
||||||
const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key});
|
const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserAvatar> createState() => _UserAvatarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarPayload {
|
||||||
|
final Uint8List bytes;
|
||||||
|
final bool isSvg;
|
||||||
|
_AvatarPayload(this.bytes, this.isSvg);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Future<_AvatarPayload?>> _avatarCache = {};
|
||||||
|
|
||||||
|
class _UserAvatarState extends State<UserAvatar> {
|
||||||
|
late Future<_AvatarPayload?> _payload;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_payload = _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(UserAvatar oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.id != widget.id ||
|
||||||
|
oldWidget.isGroup != widget.isGroup ||
|
||||||
|
oldWidget.size != widget.size) {
|
||||||
|
_payload = _load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _url() {
|
||||||
|
final host = EndpointData().nextcloud().full();
|
||||||
|
if (widget.isGroup) {
|
||||||
|
return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar';
|
||||||
|
}
|
||||||
|
return 'https://$host/avatar/${widget.id}/${widget.size}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AvatarPayload?> _load() {
|
||||||
|
final url = _url();
|
||||||
|
return _avatarCache.putIfAbsent(url, () => _fetch(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AvatarPayload?> _fetch(String url) async {
|
||||||
|
try {
|
||||||
|
final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString()));
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic $auth',
|
||||||
|
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null;
|
||||||
|
|
||||||
|
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
|
||||||
|
final bytes = response.bodyBytes;
|
||||||
|
final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes);
|
||||||
|
return _AvatarPayload(bytes, isSvg);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _looksLikeSvg(Uint8List bytes) {
|
||||||
|
final head = utf8.decode(
|
||||||
|
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
|
||||||
|
allowMalformed: true,
|
||||||
|
).trimLeft();
|
||||||
|
return head.startsWith('<?xml') || head.startsWith('<svg');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if(isGroup) {
|
final radius = widget.size.toDouble();
|
||||||
return CircleAvatar(
|
final theme = Theme.of(context);
|
||||||
foregroundImage: Image(
|
|
||||||
image: CachedNetworkImageProvider(
|
return FutureBuilder<_AvatarPayload?>(
|
||||||
'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar',
|
future: _payload,
|
||||||
errorListener: (p0) {}
|
builder: (context, snapshot) {
|
||||||
)
|
final payload = snapshot.data;
|
||||||
).image,
|
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
Widget content;
|
||||||
foregroundColor: Colors.white,
|
if (payload == null) {
|
||||||
onForegroundImageError: (o, t) {},
|
content = Icon(
|
||||||
radius: size.toDouble(),
|
widget.isGroup ? Icons.group : Icons.person,
|
||||||
child: Icon(Icons.group, size: size.toDouble()),
|
size: radius,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
} else if (payload.isSvg) {
|
||||||
|
content = SvgPicture.memory(
|
||||||
|
payload.bytes,
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
fit: BoxFit.cover,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return CircleAvatar(
|
content = Image.memory(
|
||||||
foregroundImage: Image(
|
payload.bytes,
|
||||||
image: CachedNetworkImageProvider(
|
width: radius * 2,
|
||||||
'https://${EndpointData().nextcloud().full()}/avatar/$id/$size',
|
height: radius * 2,
|
||||||
errorListener: (p0) {}
|
fit: BoxFit.cover,
|
||||||
),
|
gaplessPlayback: true,
|
||||||
).image,
|
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
onForegroundImageError: (o, t) {},
|
|
||||||
radius: size.toDouble(),
|
|
||||||
child: Icon(Icons.person, size: size.toDouble()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: theme.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
child: ClipOval(
|
||||||
|
child: SizedBox(
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
child: content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ dependencies:
|
|||||||
flutter_login: ^6.0.0
|
flutter_login: ^6.0.0
|
||||||
flutter_native_splash: ^2.4.4
|
flutter_native_splash: ^2.4.4
|
||||||
flutter_split_view: ^0.1.2
|
flutter_split_view: ^0.1.2
|
||||||
|
flutter_svg: ^2.0.10
|
||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
hydrated_bloc: ^11.0.0
|
hydrated_bloc: ^11.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user