refactored timetable

This commit is contained in:
2026-05-05 13:49:45 +02:00
parent 551c1bf1fa
commit e8faa77e70
29 changed files with 1574 additions and 300 deletions
@@ -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');
} }
+13
View File
@@ -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(
+38 -74
View File
@@ -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();
} }
} }
+120 -26
View File
@@ -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 {
content = Image.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
return CircleAvatar( return CircleAvatar(
foregroundImage: Image( radius: radius,
image: CachedNetworkImageProvider( backgroundColor: theme.primaryColor,
'https://${EndpointData().nextcloud().full()}/avatar/$id/$size',
errorListener: (p0) {}
),
).image,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
onForegroundImageError: (o, t) {}, child: ClipOval(
radius: size.toDouble(), child: SizedBox(
child: Icon(Icons.person, size: size.toDouble()), width: radius * 2,
height: radius * 2,
child: content,
),
),
);
},
); );
} }
} }
}
+1
View File
@@ -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