Skip to content
3 changes: 2 additions & 1 deletion assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,6 @@
"visibility": "Sichtbarkeit",
"utilizationAt": "Auslastung bei {}%",
"showStudentCardPicture": "Student Card Bild zeigen",
"add_contact": "Zu Kontakten hinzufügen"
"add_contact": "Zu Kontakten hinzufügen",
"noUpcomingEvents": "Keine bevorstehenden Events"
}
3 changes: 2 additions & 1 deletion assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,6 @@
"visibility": "Visibility",
"utilizationAt": "Utilization at {}%",
"showStudentCardPicture": "Show Student Card Picture",
"add_contact": "Add to contacts"
"add_contact": "Add to contacts",
"noUpcomingEvents": "No upcoming events"
}
4 changes: 4 additions & 0 deletions lib/base/util/grid_utility.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class GridUtility {
}
}

static int campusEventsCrossAxisCount(BuildContext context) {
return DeviceService.getType(context) == Device.phone ? 1 : 2;
}

static int campusNumberOfItems(BuildContext context) {
switch (DeviceService.getType(context)) {
case Device.landscapeTablet:
Expand Down
78 changes: 78 additions & 0 deletions lib/campusComponent/model/heilbronn_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'package:intl/intl.dart';
import 'package:xml/xml.dart';

class HeilbronnEvent {
final String title;
final String link;
final String description;
final DateTime? publishedAt;
final DateTime? startDate;
final DateTime? endDate;
final String? time;
final String? location;

const HeilbronnEvent({
required this.title,
required this.link,
required this.description,
this.publishedAt,
this.startDate,
this.endDate,
this.time,
this.location,
});

DateTime? get eventDate => startDate ?? publishedAt;

static List<HeilbronnEvent> listFromRss(String rss) {
try {
final document = XmlDocument.parse(rss);
final events = document
.findAllElements('item')
.map((item) {
final description = _elementText(item, 'description');
final pubDate = _parsePubDate(_elementText(item, 'pubDate'));

return HeilbronnEvent(
title: _elementText(item, 'title'),
link: _elementText(item, 'link'),
description: description,
publishedAt: pubDate,
startDate: DateTime.tryParse(_elementText(item, 'startDate')),
endDate: DateTime.tryParse(_elementText(item, 'endDate')),
time: _nullableText(item, 'time'),
location: _nullableText(item, 'location'),
);
})
.where((event) => event.title.isNotEmpty)
.toList();

events.sort((a, b) {
final firstDate = a.eventDate ?? DateTime(0);
final secondDate = b.eventDate ?? DateTime(0);
return firstDate.compareTo(secondDate);
});
return events;
} catch (_) {
return [];
}
}

static String _elementText(XmlElement item, String name) {
final elements = item.findElements(name);
return elements.isEmpty ? '' : elements.first.innerText.trim();
}

static String? _nullableText(XmlElement item, String name) {
final text = _elementText(item, name);
return text.isEmpty ? null : text;
}

static DateTime? _parsePubDate(String value) {
try {
return DateFormat('EEE, dd MMM yyyy HH:mm:ss Z', 'en_US').parse(value);
} catch (_) {
return null;
}
}
}
2 changes: 2 additions & 0 deletions lib/campusComponent/screen/campus_screen.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:campus_flutter/campusComponent/view/studentClub/student_club_widget_view.dart';
import 'package:campus_flutter/campusComponent/view/heilbronnEvents/heilbronn_events_widget_view.dart';
import 'package:campus_flutter/campusComponent/view/movie/movies_widget_view.dart';
import 'package:campus_flutter/campusComponent/view/news/news_widget_view.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -32,6 +33,7 @@ class CampusScreen extends StatelessWidget {
child: Column(
children: [
NewsWidgetView(),
HeilbronnEventsWidgetView(),
StudentClubWidgetView(),
MovieWidgetView(),
],
Expand Down
15 changes: 15 additions & 0 deletions lib/campusComponent/service/heilbronn_event_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:campus_flutter/campusComponent/model/heilbronn_event.dart';
import 'package:dio/dio.dart';

class HeilbronnEventService {
static String feedUrl(String languageCode) => languageCode == 'de'
? 'https://hn.fs.tum.de/de/events/index.xml'
: 'https://hn.fs.tum.de/events/index.xml';

static final _dio = Dio();

static Future<List<HeilbronnEvent>> fetchEvents(String languageCode) async {
final response = await _dio.get<String>(feedUrl(languageCode));
return HeilbronnEvent.listFromRss(response.data ?? '');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'package:campus_flutter/base/extensions/context.dart';
import 'package:campus_flutter/base/util/string_parser.dart';
import 'package:campus_flutter/base/util/url_launcher.dart';
import 'package:campus_flutter/campusComponent/model/heilbronn_event.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class HeilbronnEventCardView extends ConsumerWidget {
const HeilbronnEventCardView({super.key, required this.event});

final HeilbronnEvent event;

@override
Widget build(BuildContext context, WidgetRef ref) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => UrlLauncher.urlString(event.link, ref),
child: Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.hardEdge,
child: Padding(
padding: EdgeInsets.all(context.halfPadding * 1.5),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (event.eventDate != null)
Text(
StringParser.dateFormatter(event.eventDate!, context),
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (event.time != null)
Text(
event.time!,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (event.location != null)
Text(
event.location!,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:campus_flutter/base/enums/error_handling_view_type.dart';
import 'package:campus_flutter/base/errorHandling/error_handling_router.dart';
import 'package:campus_flutter/base/extensions/context.dart';
import 'package:campus_flutter/base/util/delayed_loading_indicator.dart';
import 'package:campus_flutter/base/util/grid_utility.dart';
import 'package:campus_flutter/campusComponent/model/heilbronn_event.dart';
import 'package:campus_flutter/campusComponent/view/heilbronnEvents/heilbronn_event_card_view.dart';
import 'package:campus_flutter/campusComponent/viewmodel/heilbronn_events_viewmodel.dart';
import 'package:campus_flutter/homeComponent/view/widget/widget_frame_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class HeilbronnEventsWidgetView extends ConsumerStatefulWidget {
const HeilbronnEventsWidgetView({super.key});

@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_HeilbronnEventsWidgetViewState();
}

class _HeilbronnEventsWidgetViewState
extends ConsumerState<HeilbronnEventsWidgetView> {
String? _languageCode;

@override
void didChangeDependencies() {
super.didChangeDependencies();
final languageCode = context.locale.languageCode;
if (_languageCode == languageCode) return;
_languageCode = languageCode;
ref.read(heilbronnEventsViewModel).fetch(languageCode);
}

@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: ref.watch(heilbronnEventsViewModel).isNearHeilbronn,
builder: (context, nearbySnapshot) {
if (nearbySnapshot.data == false) {
return const SizedBox.shrink();
}

return StreamBuilder(
stream: ref.watch(heilbronnEventsViewModel).events,
builder: (context, eventsSnapshot) {
return WidgetFrameView(
titleWidget: Text(
'Campus Heilbronn Events',
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: _body(eventsSnapshot),
);
},
);
},
);
}

Widget _body(AsyncSnapshot<List<HeilbronnEvent>?> snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return AspectRatio(
aspectRatio: 2,
child: Card(
child: Center(
child: Text(
'noUpcomingEvents'.tr(),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
);
}
final events = snapshot.data!
.take(GridUtility.campusNumberOfItems(context))
.toList();
final textScale = MediaQuery.textScalerOf(context).scale(1);
return Padding(
padding: EdgeInsets.symmetric(horizontal: context.padding),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: events.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: GridUtility.campusEventsCrossAxisCount(context),
mainAxisSpacing: context.padding,
crossAxisSpacing: context.padding,
mainAxisExtent: 128 * textScale,
),
itemBuilder: (context, index) =>
HeilbronnEventCardView(event: events[index]),
),
);
} else if (snapshot.hasError) {
return AspectRatio(
aspectRatio: 2,
child: Card(
child: ErrorHandlingRouter(
error: snapshot.error,
errorHandlingViewType: ErrorHandlingViewType.textOnly,
retry: (() => ref
.read(heilbronnEventsViewModel)
.fetch(context.locale.languageCode)),
),
),
);
} else {
return const AspectRatio(
aspectRatio: 2,
child: Card(
child: DelayedLoadingIndicator(name: 'Campus Heilbronn Events'),
),
);
}
}
}
64 changes: 64 additions & 0 deletions lib/campusComponent/viewmodel/heilbronn_events_viewmodel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:campus_flutter/base/services/location_service.dart';
import 'package:campus_flutter/campusComponent/model/heilbronn_event.dart';
import 'package:campus_flutter/campusComponent/service/heilbronn_event_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:rxdart/rxdart.dart';

final heilbronnEventsViewModel = Provider(
(ref) => HeilbronnEventsViewModel(),
);

class HeilbronnEventsViewModel {
static const _heilbronnLatitude = 49.1472;
static const _heilbronnLongitude = 9.2165;
static const _heilbronnRadiusMeters = 100000.0;

final BehaviorSubject<List<HeilbronnEvent>?> events =
BehaviorSubject.seeded(null);
final BehaviorSubject<bool?> isNearHeilbronn = BehaviorSubject.seeded(null);

Future<void> fetch(String languageCode) async {
Position? position;
try {
position = await LocationService.determinePosition();
} catch (_) {
position = null;
}
if (position == null ||
!_isWithinRadius(position.latitude, position.longitude)) {
isNearHeilbronn.add(false);
events.add([]);
return;
}

isNearHeilbronn.add(true);
try {
final fetchedEvents = await HeilbronnEventService.fetchEvents(
languageCode,
);
fetchedEvents.removeWhere((event) {
final eventDate = event.eventDate;
return eventDate != null && eventDate.isBefore(_today());
});
events.add(fetchedEvents);
} catch (error) {
events.addError(error);
}
}

bool _isWithinRadius(double latitude, double longitude) {
return Geolocator.distanceBetween(
latitude,
longitude,
_heilbronnLatitude,
_heilbronnLongitude,
) <=
_heilbronnRadiusMeters;
}

DateTime _today() {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day);
}
}
Loading