diff --git a/assets/translations/de.json b/assets/translations/de.json index 6b8c9d67..66e8a345 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -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" } diff --git a/assets/translations/en.json b/assets/translations/en.json index c1c49833..828ee134 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -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" } diff --git a/lib/base/util/grid_utility.dart b/lib/base/util/grid_utility.dart index a0dd39a3..11a9a787 100644 --- a/lib/base/util/grid_utility.dart +++ b/lib/base/util/grid_utility.dart @@ -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: diff --git a/lib/campusComponent/model/heilbronn_event.dart b/lib/campusComponent/model/heilbronn_event.dart new file mode 100644 index 00000000..3e9cdd60 --- /dev/null +++ b/lib/campusComponent/model/heilbronn_event.dart @@ -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 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; + } + } +} diff --git a/lib/campusComponent/screen/campus_screen.dart b/lib/campusComponent/screen/campus_screen.dart index d7f70766..ec00a4e8 100644 --- a/lib/campusComponent/screen/campus_screen.dart +++ b/lib/campusComponent/screen/campus_screen.dart @@ -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'; @@ -32,6 +33,7 @@ class CampusScreen extends StatelessWidget { child: Column( children: [ NewsWidgetView(), + HeilbronnEventsWidgetView(), StudentClubWidgetView(), MovieWidgetView(), ], diff --git a/lib/campusComponent/service/heilbronn_event_service.dart b/lib/campusComponent/service/heilbronn_event_service.dart new file mode 100644 index 00000000..cb3237a2 --- /dev/null +++ b/lib/campusComponent/service/heilbronn_event_service.dart @@ -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> fetchEvents(String languageCode) async { + final response = await _dio.get(feedUrl(languageCode)); + return HeilbronnEvent.listFromRss(response.data ?? ''); + } +} diff --git a/lib/campusComponent/view/heilbronnEvents/heilbronn_event_card_view.dart b/lib/campusComponent/view/heilbronnEvents/heilbronn_event_card_view.dart new file mode 100644 index 00000000..236b4506 --- /dev/null +++ b/lib/campusComponent/view/heilbronnEvents/heilbronn_event_card_view.dart @@ -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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/campusComponent/view/heilbronnEvents/heilbronn_events_widget_view.dart b/lib/campusComponent/view/heilbronnEvents/heilbronn_events_widget_view.dart new file mode 100644 index 00000000..449f797d --- /dev/null +++ b/lib/campusComponent/view/heilbronnEvents/heilbronn_events_widget_view.dart @@ -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 createState() => + _HeilbronnEventsWidgetViewState(); +} + +class _HeilbronnEventsWidgetViewState + extends ConsumerState { + 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?> 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'), + ), + ); + } + } +} diff --git a/lib/campusComponent/viewmodel/heilbronn_events_viewmodel.dart b/lib/campusComponent/viewmodel/heilbronn_events_viewmodel.dart new file mode 100644 index 00000000..edf6d4f5 --- /dev/null +++ b/lib/campusComponent/viewmodel/heilbronn_events_viewmodel.dart @@ -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?> events = + BehaviorSubject.seeded(null); + final BehaviorSubject isNearHeilbronn = BehaviorSubject.seeded(null); + + Future 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); + } +}