Merge branch 'develop' into develop-widgets

# Conflicts:
#	lib/view/pages/timetable/timetable.dart
#	pubspec.yaml
This commit is contained in:
2025-09-07 11:06:49 +02:00
21 changed files with 333 additions and 83 deletions

View File

@@ -1,11 +1,11 @@
import 'dart:io';
import 'package:better_open_file/better_open_file.dart';
import 'package:filesize/filesize.dart';
import 'package:flowder/flowder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:open_filex/open_filex.dart';
import '../../../widget/infoDialog.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:path_provider/path_provider.dart';

View File

@@ -66,7 +66,7 @@ class _ChatInfoState extends State<ChatInfo> {
if(participants != null) ...[
ListTile(
leading: const Icon(Icons.supervised_user_circle),
title: Text('${participants!.data.length} Teilnehmer'),
title: Text('${participants!.data.length} Mitglieder'),
trailing: const Icon(Icons.arrow_right),
onTap: () => TalkNavigator.pushSplitView(context, ParticipantsListView(participants!)),
),

View File

@@ -1,28 +1,44 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import '../../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart';
import '../../../../../widget/userAvatar.dart';
class ParticipantsListView extends StatefulWidget {
class ParticipantsListView extends StatelessWidget {
final GetParticipantsResponse participantsResponse;
const ParticipantsListView(this.participantsResponse, {super.key});
@override
State<ParticipantsListView> createState() => _ParticipantsListViewState();
}
Widget build(BuildContext context) {
lastname(participant) => participant.displayName.toString().split(' ').last;
final participants = participantsResponse.data
.sorted((a, b) => lastname(a).compareTo(lastname(b)))
.sorted((a, b) => a.participantType.index.compareTo(b.participantType.index));
var groupedParticipants = participants.groupListsBy((participant) => participant.participantType);
class _ParticipantsListViewState extends State<ParticipantsListView> {
@override
Widget build(BuildContext context) => Scaffold(
return Scaffold(
appBar: AppBar(
title: const Text('Teilnehmende'),
title: const Text('Mitglieder'),
),
body: ListView(
children: widget.participantsResponse.data.map((participant) => ListTile(
leading: UserAvatar(id: participant.actorId),
title: Text(participant.displayName),
subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null,
)).toList(),
),
children: [
...groupedParticipants.entries.map((entry) => Column(
children: [
ListTile(
title: Text(entry.key.prettyName),
titleTextStyle: TextStyle(fontWeight: FontWeight.bold),
),
...entry.value.map((participant) => ListTile(
leading: UserAvatar(id: participant.actorId),
title: Text(participant.displayName),
subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null,
)),
Divider(),
],
))
],
)
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:better_open_file/better_open_file.dart';
import 'package:bubble/bubble.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
import 'package:flowder/flowder.dart';
@@ -6,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:jiffy/jiffy.dart';
import 'package:open_filex/open_filex.dart';
import '../../../../extensions/text.dart';
import 'package:provider/provider.dart';
@@ -206,7 +206,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
),
),
Visibility(
visible: !message.containsFile,
visible: widget.bubbleData.message != '{file}',
child: ListTile(
leading: const Icon(Icons.copy),
title: const Text('Nachricht kopieren'),
@@ -323,7 +323,9 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
return;
}
downloadProgress = 1;
setState(() {
downloadProgress = 1;
});
downloadCore = FileElement.download(context, message.file!.path!, message.file!.name, (progress) {
if(progress > 1) {
setState(() {
@@ -408,7 +410,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(value: downloadProgress/100),
child: LinearProgressIndicator(value: downloadProgress == 1 ? null : downloadProgress/100),
),
),
],

View File

@@ -21,39 +21,49 @@ class ChatMessage {
ChatMessage({required this.originalMessage, this.originalData}) {
if(originalData?.containsKey('file') ?? false) {
file = originalData?['file'];
content = file?.name ?? 'Datei';
} else {
content = RichObjectStringProcessor.parseToString(originalMessage, originalData);
}
content = RichObjectStringProcessor.parseToString(originalMessage, originalData);
}
Widget getWidget() {
if(file == null) {
return Linkify(
text: content,
onOpen: onOpen,
);
}
var contentWidget = Linkify(
text: content,
onOpen: onOpen,
);
return Padding(padding: const EdgeInsets.only(top: 5), child: CachedNetworkImage(
errorWidget: (context, url, error) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
if(file == null) return contentWidget;
return Padding(
padding: const EdgeInsets.only(top: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.file_open_outlined, size: 35),
const SizedBox(width: 10),
Flexible(child: Text(file!.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 10),
CachedNetworkImage(
errorWidget: (context, url, error) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.file_open_outlined, size: 35),
const SizedBox(width: 10),
Flexible(child: Text(file!.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 10),
],
),
alignment: Alignment.center,
placeholder: (context, url) => const Padding(padding: EdgeInsets.all(15), child: SizedBox(width: 50, child: LinearProgressIndicator())),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorListener: (value) {},
imageUrl: 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
),
if(originalMessage != '{file}') ...[
SizedBox(height: 5),
contentWidget
]
],
),
alignment: Alignment.center,
placeholder: (context, url) => const Padding(padding: EdgeInsets.all(15), child: SizedBox(width: 50, child: LinearProgressIndicator())),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorListener: (value) {},
imageUrl: 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=100&y=-1&a=1',
));
)
);
}
Future<void> onOpen(LinkableElement link) async {

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -38,7 +37,7 @@ class _MessageReactionsState extends State<MessageReactions> {
future: data,
builder: (context, snapshot) {
if(snapshot.connectionState == ConnectionState.waiting) return const LoadingSpinner();
if(snapshot.data == null) return const PlaceholderView(icon: Icons.search_off_outlined, text: 'Keine Reaktionen gefunden!');
if(snapshot.data!.data.isEmpty) return const PlaceholderView(icon: Icons.search_off_outlined, text: 'Keine Reaktionen gefunden!');
return ListView(
children: [
...snapshot.data!.data.entries.map<Widget>((entry) => ExpansionTile(

View File

@@ -1,17 +1,24 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../extensions/dateTime.dart';
import '../../../homescreen_widgets/timetable/timetableHomeWidget.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../storage/base/settingsProvider.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/placeholderView.dart';
import 'arbitraryAppointment.dart';
import 'calendar.dart';
import 'customTimetableColors.dart';
import 'customTimetableEventEditDialog.dart';
import 'timetableEvents.dart';
import 'timetableNameMode.dart';
import 'viewCustomTimetableEvents.dart';
class Timetable extends StatefulWidget {
@@ -31,6 +38,7 @@ class _TimetableState extends State<Timetable> {
@override
void initState() {
settings = Provider.of<SettingsProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
Provider.of<TimetableProps>(context, listen: false).run();
});
@@ -131,4 +139,212 @@ class _TimetableState extends State<Timetable> {
updateTimings.cancel();
super.dispose();
}
List<TimeRegion> _buildSpecialTimeRegions(GetHolidaysResponse holidays) {
var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
var firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
var secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
var holidayList = holidays.result.map((holiday) {
var startDay = _parseWebuntisTimestamp(holiday.startDate, 0);
var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0)
.difference(startDay)
.inDays;
var days = List<DateTime>.generate(dayCount, (index) => startDay.add(Duration(days: index)));
return days.map((holidayDay) => TimeRegion(
startTime: holidayDay.copyWith(hour: 07, minute: 55),
endTime: holidayDay.copyWith(hour: 16, minute: 30),
text: 'holiday:${holiday.name}',
color: Theme
.of(context)
.disabledColor
.withAlpha(50),
iconData: Icons.holiday_village_outlined
));
}).expand((e) => e);
bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time));
return [
...holidayList,
if(!isInHoliday(firstBreak))
TimeRegion(
startTime: firstBreak,
endTime: firstBreak.add(const Duration(minutes: 20)),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: 'centerIcon',
color: Theme.of(context).primaryColor.withAlpha(50),
iconData: Icons.restaurant
),
if(!isInHoliday(secondBreak))
TimeRegion(
startTime: secondBreak,
endTime: secondBreak.add(const Duration(minutes: 15)),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: 'centerIcon',
color: Theme.of(context).primaryColor.withAlpha(50),
iconData: Icons.restaurant
),
];
}
List<GetTimetableResponseObject> _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) {
var timetableList = data.getTimetableResponse.result.toList();
if(timetableList.isEmpty) return timetableList;
timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime)));
var previousElement = timetableList.first;
for(var i = 1; i < timetableList.length; i++) {
var currentElement = timetableList.elementAt(i);
bool isSameLesson() {
var currentSubjectId = currentElement.su.firstOrNull?.id;
var previousSubjectId = previousElement.su.firstOrNull?.id;
if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false;
var currentRoomId = currentElement.ro.firstOrNull?.id;
var previousRoomId = previousElement.ro.firstOrNull?.id;
if(currentRoomId != previousRoomId) return false;
var currentTeacherId = currentElement.te.firstOrNull?.id;
var previousTeacherId = previousElement.te.firstOrNull?.id;
if(currentTeacherId != previousTeacherId) return false;
var currentStatusCode = currentElement.code;
var previousStatusCode = previousElement.code;
if(currentStatusCode != previousStatusCode) return false;
return true;
}
bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble)
.isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime));
if(isSameLesson() && isNotSeparated()) {
previousElement.endTime = currentElement.endTime;
timetableList.remove(currentElement);
i--;
} else {
previousElement = currentElement;
}
}
return timetableList;
}
TimetableEvents _buildTableEvents(TimetableProps data) {
var timetableList = data.getTimetableResponse.result.toList();
if(settings.val().timetableSettings.connectDoubleLessons) {
timetableList = _removeDuplicates(data, const Duration(minutes: 5));
}
var appointments = timetableList.map((element) {
var rooms = data.getRoomsResponse;
var subjects = data.getSubjectsResponse;
try {
var startTime = _parseWebuntisTimestamp(element.date, element.startTime);
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id);
var subjectName = 'Unbekannt';
if(subject != null) {
subjectName = {
TimetableNameMode.name: subject.name,
TimetableNameMode.longName: subject.longName,
TimetableNameMode.alternateName: subject.alternateName,
}[settings.val().timetableSettings.timetableNameMode]!;
}
return Appointment(
id: ArbitraryAppointment(webuntis: element),
startTime: startTime,
endTime: endTime,
subject: subjectName,
location: ''
'${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}'
'\n'
'${element.te.firstOrNull?.longname ?? 'Unbekannt'}',
notes: element.activityType,
color: _getEventColor(element, startTime, endTime),
);
} catch(e) {
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
return Appointment(
id: ArbitraryAppointment(webuntis: element),
startTime: _parseWebuntisTimestamp(element.date, element.startTime),
endTime: endTime,
subject: 'Änderung',
notes: element.info,
location: 'Unbekannt',
color: const Color(0xff404040).withAlpha(endTime.isBefore(DateTime.now()) ? 100 : 255),
startTimeZone: '',
endTimeZone: '',
);
}
}).toList();
appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment(
id: ArbitraryAppointment(custom: customEvent),
startTime: customEvent.startDate,
endTime: customEvent.endDate,
location: customEvent.description,
subject: customEvent.title,
recurrenceRule: customEvent.rrule,
color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
endTimeZone: '',
)));
return TimetableEvents(appointments);
}
DateTime _parseWebuntisTimestamp(int date, int time) {
var timeString = time.toString().padLeft(4, '0');
return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
}
Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) {
// Make element darker, when it already took place
var alpha = endTime.isBefore(DateTime.now()) ? 100 : 255;
// Cancelled
if(webuntisElement.code == 'cancelled') return const Color(0xff000000).withAlpha(alpha);
// Any changes or no teacher at this element
if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3).withAlpha(alpha);
// Teacher has changed
if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B).withAlpha(alpha);
// Event was in the past
if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withAlpha(alpha);
// Event takes currently place
if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200);
// Fallback
return Theme.of(context).primaryColor.withAlpha(alpha);
}
bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) {
var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment;
if(appointment.hasWebuntis()) {
return appointment.webuntis!.code == 'cancelled';
}
return false;
}
}