From eebf259d87c4fed629491a165bf93e78aa97e2a7 Mon Sep 17 00:00:00 2001 From: Saina Amiri <90347142+Sainaamr@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:07:30 +0100 Subject: [PATCH] Fix continue without and other bugs (#256) * use enum for section kind * fix the no live stream bug * separate course section and live stream section * separate course card and small stream card * reduce logged in * fetch live now thumbnails * fetch live now thumbnails * fetch live now thumbnails --- lib/models/video/stream_state_model.dart | 11 +- lib/utils/section_kind.dart | 1 + lib/view_models/stream_view_model.dart | 58 +++-- .../course_view/components/course_card.dart | 207 +++------------ .../components/course_section.dart | 203 +++------------ .../components/live_stream_section.dart | 143 +++++++++++ .../components/small_stream_card.dart | 235 ++++++++++++++++++ .../course_view/components/stream_card.dart | 3 +- lib/views/course_view/courses_overview.dart | 96 ++++--- .../list_courses_view/courses_list_view.dart | 4 - .../pinned_courses_view/pinned_card.dart | 36 --- .../pinned_courses_view.dart | 1 - 12 files changed, 550 insertions(+), 448 deletions(-) create mode 100644 lib/utils/section_kind.dart create mode 100644 lib/views/course_view/components/live_stream_section.dart create mode 100644 lib/views/course_view/components/small_stream_card.dart delete mode 100644 lib/views/course_view/pinned_courses_view/pinned_card.dart diff --git a/lib/models/video/stream_state_model.dart b/lib/models/video/stream_state_model.dart index 61195212..b4626af0 100644 --- a/lib/models/video/stream_state_model.dart +++ b/lib/models/video/stream_state_model.dart @@ -8,12 +8,12 @@ class StreamState { final bool isLoading; final List? streams; final List? liveStreams; - final List? thumbnails; final AppError? error; final Progress? progress; final bool isWatched; final String? videoSource; final List>? streamsWithThumb; + final List>? liveStreamsWithThumb; final List>? displayedStreams; final String selectedFilterOption; @@ -21,12 +21,12 @@ class StreamState { this.isLoading = false, this.streams, this.liveStreams, - this.thumbnails, this.error, this.progress, this.isWatched = false, this.videoSource, this.streamsWithThumb, + this.liveStreamsWithThumb, this.displayedStreams, this.selectedFilterOption = 'Oldest First', }); @@ -42,6 +42,7 @@ class StreamState { String? videoSource, Map? downloadedVideos, List>? streamsWithThumb, + List>? liveStreamsWithThumb, List>? displayedStreams, String? selectedFilterOption, }) { @@ -49,12 +50,12 @@ class StreamState { isLoading: isLoading ?? this.isLoading, streams: streams ?? this.streams, liveStreams: liveStreams ?? this.liveStreams, - thumbnails: thumbnails ?? this.thumbnails, error: error ?? this.error, progress: progress ?? this.progress, isWatched: isWatched ?? this.isWatched, videoSource: videoSource ?? this.videoSource, streamsWithThumb: streamsWithThumb ?? this.streamsWithThumb, + liveStreamsWithThumb: liveStreamsWithThumb ?? this.liveStreamsWithThumb, displayedStreams: displayedStreams ?? this.displayedStreams, selectedFilterOption: selectedFilterOption ?? this.selectedFilterOption, ); @@ -65,11 +66,11 @@ class StreamState { isLoading: isLoading, streams: streams, liveStreams: liveStreams, - thumbnails: thumbnails, progress: progress, isWatched: isWatched, videoSource: videoSource, streamsWithThumb: streamsWithThumb, + liveStreamsWithThumb: liveStreamsWithThumb, displayedStreams: displayedStreams, error: null, ); @@ -80,11 +81,11 @@ class StreamState { isLoading: isLoading, streams: streams, liveStreams: liveStreams, - thumbnails: thumbnails, progress: progress, isWatched: isWatched, videoSource: videoSource, streamsWithThumb: streamsWithThumb, + liveStreamsWithThumb: liveStreamsWithThumb, displayedStreams: displayedStreams, error: null, ); diff --git a/lib/utils/section_kind.dart b/lib/utils/section_kind.dart new file mode 100644 index 00000000..61edbb44 --- /dev/null +++ b/lib/utils/section_kind.dart @@ -0,0 +1 @@ +enum SectionKind { livestreams, myCourses, publicCourses } diff --git a/lib/view_models/stream_view_model.dart b/lib/view_models/stream_view_model.dart index 12c3de1b..ad128a18 100644 --- a/lib/view_models/stream_view_model.dart +++ b/lib/view_models/stream_view_model.dart @@ -24,6 +24,28 @@ class StreamViewModel extends StateNotifier { } } + + + void updatedDisplayedStreams(List> allStreams) { + state = state.copyWith(displayedStreams: allStreams); + } + + void setUpDisplayedCourses(List> allStreams) { + updatedDisplayedStreams( + CourseUtils.sortStreams(allStreams, state.selectedFilterOption), + ); + } + + void updateSelectedFilterOption( + String option, + List> allStreams, + ) { + state = state.copyWith(selectedFilterOption: option); + updatedDisplayedStreams( + CourseUtils.sortStreams(allStreams, state.selectedFilterOption), + ); + } + /// This asynchronous function fetches thumbnails for all available streams in the current state. /// only if there are streams in the current state. /// It initiates fetching of thumbnails for each stream by invoking `fetchThumbnailForStream`. @@ -47,23 +69,25 @@ class StreamViewModel extends StateNotifier { setUpDisplayedCourses(fetchedStreamsWithThumbnails); } - void updatedDisplayedStreams(List> allStreams) { - state = state.copyWith(displayedStreams: allStreams); - } - - void setUpDisplayedCourses(List> allStreams) { - updatedDisplayedStreams( - CourseUtils.sortStreams(allStreams, state.selectedFilterOption), - ); - } - - void updateSelectedFilterOption( - String option, - List> allStreams, - ) { - state = state.copyWith(selectedFilterOption: option); - updatedDisplayedStreams( - CourseUtils.sortStreams(allStreams, state.selectedFilterOption), + /// This asynchronous function fetches thumbnails for all available live streams in the current state. + /// only if there are live streams in the current state. + /// It initiates fetching of thumbnails for each live stream by invoking `fetchThumbnailForStream`. + Future fetchLiveThumbnails() async { + if (state.liveStreams == null) { + return; + } + var fetchLiveThumbnailTasks = >>[]; + for (var stream in state.liveStreams!) { + fetchLiveThumbnailTasks.add( + fetchStreamThumbnail(stream.id) + .then((thumbnail) => Tuple2(stream, thumbnail)), + ); + } + var fetchedStreamsWithThumbnails = + await Future.wait(fetchLiveThumbnailTasks); + state = state.copyWith( + liveStreamsWithThumb: fetchedStreamsWithThumbnails, + isLoading: false, ); } diff --git a/lib/views/course_view/components/course_card.dart b/lib/views/course_view/components/course_card.dart index 062d0bde..1239abef 100644 --- a/lib/views/course_view/components/course_card.dart +++ b/lib/views/course_view/components/course_card.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; -import 'package:gocast_mobile/views/components/view_all_button.dart'; -import 'package:url_launcher/url_launcher.dart'; class CourseCard extends StatelessWidget { final String title; @@ -11,38 +8,25 @@ class CourseCard extends StatelessWidget { final VoidCallback onTap; final int courseId; - final bool isCourse; //true: course, false: stream - final WidgetRef? ref; //for displaying courses final bool? live; - final String? semester; + final Course? course; final Function(Course)? onPinUnpin; final bool? isPinned; //for displaying livestreams final String? subtitle; - final String? roomName; - final String? roomNumber; - final String? viewerCount; - final String? path; const CourseCard({ super.key, - this.ref, - required this.isCourse, required this.title, this.subtitle, required this.tumID, - this.roomName, - this.roomNumber, - this.viewerCount, - this.path, required this.courseId, required this.onTap, this.live, - this.semester, this.course, this.onPinUnpin, this.isPinned, @@ -74,66 +58,19 @@ class CourseCard extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8.0), - child: isCourse - ? _buildCourseCard( - themeData, - cardWidth, - context, - course!, + child: _buildCourseCard( + themeData, + cardWidth, + context, + course!, onPinUnpin!, isPinned!, ) - : _buildStreamCard( - themeData, - cardWidth, - ), ), ), ); } - Widget _buildStreamCard(ThemeData themeData, double cardWidth) { - return Container( - width: cardWidth, - padding: const EdgeInsets.all(8.0), - color: themeData.cardColor, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildCourseTumID(), - _buildCourseViewerCount(themeData), - ], - ), - const SizedBox(height: 2), - Row( - children: [ - SizedBox( - width: 100, - child: _buildCourseImage(), - ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 10), - _buildCourseTitle(themeData.textTheme), - _buildCourseSubtitle(themeData.textTheme), - const SizedBox(height: 15), - _buildLocation(), - ], - ), - ), - ], - ), - ], - ), - ); - } - Widget _buildCourseCard( ThemeData themeData, double cardWidth, @@ -228,114 +165,9 @@ class CourseCard extends StatelessWidget { false; } - Widget _buildCourseImage() { - if (path == null) return const SizedBox(); - return Stack( - children: [ - AspectRatio( - aspectRatio: 16 / 12, - child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: Image.asset( - path!, - fit: BoxFit.cover, - ), - ), - ), - ], - ); - } - - Widget _buildLocation() { - if (roomNumber == null) return const SizedBox(); - final Uri url = Uri.parse('https://nav.tum.de/room/$roomNumber'); - return InkWell( - onTap: () async { - if (await canLaunchUrl(url)) { - await launchUrl(url); - } else { - throw 'Could not launch $url'; - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Icon( - Icons.room, - size: 20, - ), - Text(roomName ?? "Location"), - Transform.scale( - scale: 0.6, // Adjust the scale factor as needed - child: ViewAllButton(onViewAll: () {}), - ), - ], - ), - ); - } - Widget _buildCourseTumID() { - return Text( - tumID, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - color: Colors.grey, - height: 0.9, - ), - ); - } - - Widget _buildCourseTitle(TextTheme textTheme) { - return Text( - title, - overflow: TextOverflow.ellipsis, - maxLines: 2, - softWrap: true, - style: textTheme.titleMedium?.copyWith( - fontSize: 16, - fontWeight: FontWeight.w700, - height: 1.2, - ) ?? - const TextStyle(), - ); - } - - Widget _buildCourseSubtitle(TextTheme textTheme) { - if (subtitle == null) return const SizedBox(); - - return Text( - subtitle!, //nullcheck already passed - overflow: TextOverflow.ellipsis, - style: textTheme.labelSmall?.copyWith( - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1, - ) ?? - const TextStyle(), - ); - } - - Widget _buildCourseViewerCount(ThemeData themeData) { - if (viewerCount == null) return const SizedBox(); - return Container( - decoration: BoxDecoration( - color: themeData.shadowColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.all(3), - child: Text( - "${viewerCount!} viewers", - style: themeData.textTheme.labelSmall?.copyWith( - fontSize: 12, - height: 1, - ) ?? - const TextStyle(), - ), - ); - } Widget _buildCourseIsLive() { if (live == null) return const SizedBox(); @@ -386,4 +218,31 @@ class CourseCard extends StatelessWidget { return Colors.grey; } } + + Widget _buildCourseTitle(TextTheme textTheme) { + return Text( + title, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: true, + style: textTheme.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.2, + ) ?? + const TextStyle(), + ); + } + + Widget _buildCourseTumID() { + return Text( + tumID, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + height: 0.9, + ), + ); + } } diff --git a/lib/views/course_view/components/course_section.dart b/lib/views/course_view/components/course_section.dart index 5c6d2af5..a383298f 100644 --- a/lib/views/course_view/components/course_section.dart +++ b/lib/views/course_view/components/course_section.dart @@ -1,15 +1,12 @@ -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; import 'package:gocast_mobile/providers.dart'; -import 'package:gocast_mobile/utils/constants.dart'; +import 'package:gocast_mobile/utils/section_kind.dart'; import 'package:gocast_mobile/views/components/view_all_button.dart'; import 'package:gocast_mobile/views/course_view/components/course_card.dart'; -import 'package:gocast_mobile/views/course_view/components/pulse_background.dart'; import 'package:gocast_mobile/views/course_view/course_detail_view/course_detail_view.dart'; -import 'package:gocast_mobile/views/video_view/video_player.dart'; /// CourseSection /// @@ -28,12 +25,13 @@ import 'package:gocast_mobile/views/video_view/video_player.dart'; /// different titles, courses and onViewAll actions. class CourseSection extends StatelessWidget { final String sectionTitle; - final int + final SectionKind sectionKind; //0 for livestreams, 1 cor mycourses, 2 for puliccourses final List courses; final List streams; final VoidCallback? onViewAll; final WidgetRef ref; + final String baseUrl = 'https://live.rbg.tum.de'; const CourseSection({ super.key, @@ -51,68 +49,24 @@ class CourseSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildCourseSectionOrMessage( - ref: ref, - context: context, - title: sectionTitle, - sectionKind: sectionKind, - onViewAll: onViewAll, - courses: courses, - streams: streams, - ), + _buildCourseSection(context), ], ), ); } - Widget _buildCourseSectionOrMessage({ - required WidgetRef ref, - required BuildContext context, - required String title, - required int sectionKind, - VoidCallback? onViewAll, - required List courses, - required List streams, - }) { - return (streams.isNotEmpty - ? _buildCourseSection( - ref: ref, - context: context, - title: title, - onViewAll: onViewAll, - courses: courses, - streams: streams, - sectionKind: sectionKind, - ) - : _buildNoCoursesMessage(context, title)); - } - - Widget _buildCourseSection({ - required WidgetRef ref, - required BuildContext context, - required String title, - VoidCallback? onViewAll, - required List courses, - required List streams, - required int sectionKind, - }) { + Widget _buildCourseSection(BuildContext context) { return Padding( padding: const EdgeInsets.all(10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - sectionKind == 0 - ? const PulsingBackground() - : _buildSectionTitle( + _buildCoursesTitle(context), + courses.isEmpty + ? _buildNoCoursesMessage( context, - title, - sectionKind == 1 ? Icons.school : Icons.public, - onViewAll, - ), - if (sectionKind == 1 || sectionKind == 2) - _buildCourseList(context) - else if (sectionKind == 0) - _buildStreamList(context), + ) // If streams are empty, show no courses message + : _buildCourseList(context), ], ), ); @@ -129,29 +83,18 @@ class CourseSection extends StatelessWidget { scrollDirection: Axis.vertical, itemCount: courses.length, itemBuilder: (BuildContext context, int index) { - /// Those are temporary values until we get the real data from the API - final Random random = Random(); final course = courses[index]; - String imagePath; - List imagePaths = [ - AppImages.course1, - AppImages.course2, - ]; - imagePath = imagePaths[random.nextInt(imagePaths.length)]; - final userPinned = ref.watch(pinnedCourseViewModelProvider).userPinned ?? []; + final userPinned = + ref.watch(pinnedCourseViewModelProvider).userPinned ?? []; + final isPinned = userPinned.contains(course); return CourseCard( course: course, isPinned: isPinned, onPinUnpin: (course) => _togglePin(course, isPinned), - isCourse: true, - ref: ref, title: course.name, tumID: course.tUMOnlineIdentifier, - path: imagePath, live: streams.any((stream) => stream.courseID == course.id), - semester: - course.semester.teachingTerm + course.semester.year.toString(), courseId: course.id, onTap: () { Navigator.push( @@ -184,107 +127,42 @@ class CourseSection extends StatelessWidget { await ref.read(pinnedCourseViewModelProvider.notifier).fetchUserPinned(); } - Widget _buildStreamList(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: streams.map((stream) { - final Random random = Random(); - String imagePath; - List imagePaths = [ - AppImages.course1, - AppImages.course2, - ]; - imagePath = imagePaths[random.nextInt(imagePaths.length)]; - - final course = - courses.where((course) => course.id == stream.courseID).first; - - return CourseCard( - isCourse: false, - ref: ref, - title: stream.name, - subtitle: course.name, - tumID: course.tUMOnlineIdentifier, - roomName: stream.roomName, - roomNumber: stream.roomCode, - viewerCount: stream.vodViews.toString(), - path: imagePath, - courseId: course.id, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoPlayerPage( - stream: stream, - ), - ), - ); - }, - ); - }).toList(), - ), - ); - } + Widget _buildCoursesTitle(BuildContext context) { + IconData icon = + sectionKind == SectionKind.myCourses ? Icons.school : Icons.public; - Row _buildSectionTitle( - BuildContext context, - String title, - IconData? icon, - VoidCallback? onViewAll, - ) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - onViewAll != null - ? Row( - children: [ - icon != null - ? Row( - children: [ - Icon(icon), - const SizedBox(width: 10), - ], - ) - : const SizedBox(), - Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ) - : const PulsingBackground(), - onViewAll != null - ? ViewAllButton(onViewAll: onViewAll) - : const SizedBox(), + Row( + children: [ + Icon(icon), + const SizedBox(width: 10), + Text( + sectionTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ViewAllButton(onViewAll: onViewAll), ], ); } - Widget _buildNoCoursesMessage(BuildContext context, String title) { + Widget _buildNoCoursesMessage(BuildContext context) { return Padding( padding: const EdgeInsets.all(10.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - ], - ), const SizedBox(height: 20), const Center( - child: Icon(Icons.folder_open, size: 50, color: Colors.grey), + child: Icon(Icons.not_interested, size: 50, color: Colors.grey), ), const SizedBox(height: 8), Center( child: Text( - 'No $title found', + 'No $sectionTitle found', textAlign: TextAlign.center, style: const TextStyle( fontSize: 18, @@ -294,29 +172,8 @@ class CourseSection extends StatelessWidget { ), ), const SizedBox(height: 12), - Center( - child: ElevatedButton( - onPressed: () {}, - child: Text( - _buttonText(title), - ), - ), - ), ], ), ); } - - String _buttonText(String title) { - switch (title) { - case 'My courses': - return 'Enroll in a Course'; - case 'Live Now': - return 'No courses currently live'; - case 'Public courses': - return 'No public courses found'; - default: - return 'Discover Courses'; - } - } } diff --git a/lib/views/course_view/components/live_stream_section.dart b/lib/views/course_view/components/live_stream_section.dart new file mode 100644 index 00000000..7195934a --- /dev/null +++ b/lib/views/course_view/components/live_stream_section.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; + +import 'package:gocast_mobile/views/course_view/components/pulse_background.dart'; +import 'package:gocast_mobile/views/course_view/components/small_stream_card.dart'; +import 'package:gocast_mobile/views/video_view/video_player.dart'; +import 'package:tuple/tuple.dart'; + +/// CourseSection +/// +/// A reusable stateless widget to display a specific course section. +/// +/// It takes a [sectionTitle] to display the title of the section and +/// dynamically generates a horizontal list of courses. This widget can be +/// reused for various course sections by providing different titles and +/// course lists. +/// +/// This widget also takes an [onViewAll] action to define the action to be +/// performed when the user taps on the View All button. +/// This widget also takes a [courses] list to display the list of courses. +/// If no courses are provided, it will display a default list of courses. +/// This widget can be reused for various course sections by providing +/// different titles, courses and onViewAll actions. +class LiveStreamSection extends StatelessWidget { + final String sectionTitle; + final List courses; + final List> streams; + final VoidCallback? onViewAll; + final WidgetRef ref; + final String baseUrl = 'https://live.rbg.tum.de'; + + const LiveStreamSection({ + super.key, + required this.ref, + required this.sectionTitle, + required this.streams, + this.onViewAll, + required this.courses, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCourseSection(context), + ], + ), + ); + } + + Widget _buildCourseSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PulsingBackground(), + // Use the ternary conditional operator for inline conditions + streams.isEmpty + ? _buildNoCoursesMessage( + context, + ) // If streams are empty, show no courses message + : _buildStreamList(context), // Otherwise, show the stream list + ], + ), + ); + } + + Widget _buildStreamList(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: streams.map((stream) { + String imagePath; + + imagePath = _getThumbnailUrl(stream.item2); + + final course = courses + .where((course) => course.id == stream.item1.courseID) + .first; + + return SmallStreamCard( + title: stream.item1.name, + subtitle: course.name, + tumID: course.tUMOnlineIdentifier, + roomName: stream.item1.roomName, + roomNumber: stream.item1.roomCode, + path: imagePath, + courseId: course.id, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerPage( + stream: stream.item1, + ), + ), + ); + }, + ); + }).toList(), + ), + ); + } + + Widget _buildNoCoursesMessage(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + const Center( + child: Icon(Icons.not_interested, size: 50, color: Colors.grey), + ), + const SizedBox(height: 8), + Center( + child: Text( + 'No $sectionTitle found', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 12), + ], + ), + ); + } + + String _getThumbnailUrl(String thumbnail) { + if (!thumbnail.startsWith('http')) { + thumbnail = '$baseUrl$thumbnail'; + } + return thumbnail; + } +} diff --git a/lib/views/course_view/components/small_stream_card.dart b/lib/views/course_view/components/small_stream_card.dart new file mode 100644 index 00000000..e894a034 --- /dev/null +++ b/lib/views/course_view/components/small_stream_card.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; +import 'package:gocast_mobile/utils/constants.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SmallStreamCard extends StatelessWidget { + final String title; + final String tumID; + final VoidCallback onTap; + final int courseId; + + //for displaying courses + final bool? live; + + final Course? course; + + //for displaying livestreams + final String? subtitle; + final String? roomName; + final String? roomNumber; + + final String? path; + + const SmallStreamCard({ + super.key, + required this.title, + this.subtitle, + required this.tumID, + this.roomName, + this.roomNumber, + this.path, + required this.courseId, + required this.onTap, + this.live, + this.course, + }); + + @override + Widget build(BuildContext context) { + ThemeData themeData = Theme.of(context); + + double cardWidth = MediaQuery.of(context).size.width >= 600 + ? MediaQuery.of(context).size.width * 0.4 + : MediaQuery.of(context).size.width * 0.9; + + return InkWell( + onTap: onTap, + child: Card( + elevation: 1, + shadowColor: themeData.shadowColor, + color: themeData.cardTheme.color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: BorderSide( + color: themeData + .inputDecorationTheme.enabledBorder?.borderSide.color + .withOpacity(0.2) ?? + Colors.grey.withOpacity(0.2), + width: 1.0, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: _buildStreamCard( + themeData, + cardWidth, + ), + ), + ), + ); + } + + Widget _buildStreamCard(ThemeData themeData, double cardWidth) { + return Container( + width: cardWidth, + padding: const EdgeInsets.all(8.0), + color: themeData.cardColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildCourseTumID(), + ], + ), + const SizedBox(height: 2), + Row( + children: [ + SizedBox( + width: 100, + child: _buildCourseImage(), + ), + const SizedBox(width: 15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + _buildCourseTitle(themeData.textTheme), + _buildCourseSubtitle(themeData.textTheme), + const SizedBox(height: 15), + _buildLocation(themeData), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildCourseImage() { + // Assuming `path` is now a URL string + return Stack( + children: [ + AspectRatio( + aspectRatio: 16 / 12, // Maintain the same aspect ratio + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + // Keep the rounded corners + child: Image.network( + path!, // Use the image URL + fit: BoxFit.cover, // Maintain the cover fit + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) + return child; // Image is fully loaded + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + (loadingProgress.expectedTotalBytes ?? 1) + : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + // Provide a fallback asset image in case of error + return Image.asset( + AppImages.course1, + // Path to your default/fallback image asset + fit: BoxFit.cover, + ); + }, + ), + ), + ), + // If you have additional overlays like in the thumbnail widget, add them here + ], + ); + } + + Widget _buildLocation(ThemeData themeData) { + if (roomNumber == null) return const SizedBox(); + + final Uri url = Uri.parse('https://nav.tum.de/room/$roomNumber'); + + return Align( + alignment: Alignment.centerRight, // Align to the right + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: themeData + .inputDecorationTheme.enabledBorder?.borderSide.color + .withOpacity(0.4) ?? + Colors.grey.withOpacity(0.4), + ), + borderRadius: BorderRadius.circular(5), + ), + child: InkWell( + onTap: () async { + if (await canLaunchUrl(url)) { + await launchUrl(url); + } else { + throw 'Could not launch $url'; + } + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + // Constrain row size to its children + children: [ + Icon(Icons.room, size: 24), + SizedBox(width: 8), // Spacing before the arrow icon + Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ), + ); + } + + Widget _buildCourseTumID() { + return Text( + tumID, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + height: 0.9, + ), + ); + } + + Widget _buildCourseTitle(TextTheme textTheme) { + return Text( + title, + overflow: TextOverflow.ellipsis, + maxLines: 2, + softWrap: true, + style: textTheme.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w700, + height: 1.2, + ) ?? + const TextStyle(), + ); + } + + Widget _buildCourseSubtitle(TextTheme textTheme) { + if (subtitle == null) return const SizedBox(); + + return Text( + subtitle!, //nullcheck already passed + overflow: TextOverflow.ellipsis, + style: textTheme.labelSmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1, + ) ?? + const TextStyle(), + ); + } +} diff --git a/lib/views/course_view/components/stream_card.dart b/lib/views/course_view/components/stream_card.dart index b23d93f3..21868822 100644 --- a/lib/views/course_view/components/stream_card.dart +++ b/lib/views/course_view/components/stream_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; import 'package:gocast_mobile/providers.dart'; +import 'package:gocast_mobile/utils/constants.dart'; import 'package:gocast_mobile/views/video_view/video_player.dart'; import 'package:intl/intl.dart'; @@ -119,7 +120,7 @@ class StreamCardState extends ConsumerState { }, errorBuilder: (context, error, stackTrace) { return Image.asset( - 'assets/images/default_image.png', + AppImages.course1, fit: BoxFit.cover, ); }, diff --git a/lib/views/course_view/courses_overview.dart b/lib/views/course_view/courses_overview.dart index 1a363c94..27796cba 100644 --- a/lib/views/course_view/courses_overview.dart +++ b/lib/views/course_view/courses_overview.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:gocast_mobile/providers.dart'; +import 'package:gocast_mobile/utils/section_kind.dart'; import 'package:gocast_mobile/views/components/base_view.dart'; import 'package:gocast_mobile/views/course_view/components/course_section.dart'; +import 'package:gocast_mobile/views/course_view/components/live_stream_section.dart'; import 'package:gocast_mobile/views/course_view/list_courses_view/my_courses_view.dart'; import 'package:gocast_mobile/views/course_view/list_courses_view/public_courses_view.dart'; import 'package:gocast_mobile/views/settings_view/settings_screen_view.dart'; @@ -16,6 +18,7 @@ class CourseOverview extends ConsumerStatefulWidget { } class CourseOverviewState extends ConsumerState { + bool isLoading = true; @override void initState() { super.initState(); @@ -24,25 +27,31 @@ class CourseOverviewState extends ConsumerState { ref.read(pinnedCourseViewModelProvider.notifier); final videoViewModelNotifier = ref.read(videoViewModelProvider.notifier); - Future.microtask(() { - // Fetch user courses if the user is logged in - if (ref.read(userViewModelProvider).user != null) { - userViewModelNotifier.fetchUserCourses(); - videoViewModelNotifier.fetchLiveNowStreams(); - pinnedViewModelNotifier.fetchUserPinned(); - } - // Fetch public courses regardless of user's login status - userViewModelNotifier.fetchPublicCourses(); - userViewModelNotifier.fetchSemesters(); + Future.microtask(() async { + await userViewModelNotifier.fetchUserCourses(); + await videoViewModelNotifier.fetchLiveNowStreams(); + await videoViewModelNotifier.fetchLiveThumbnails(); + await pinnedViewModelNotifier.fetchUserPinned(); + await userViewModelNotifier.fetchPublicCourses(); + await userViewModelNotifier.fetchSemesters(); + + setState(() { + isLoading = false; // Set loading to false once data is fetched + }); }); } @override Widget build(BuildContext context) { - final isLoggedIn = ref.watch(userViewModelProvider).user != null; - final userCourses = ref.watch(userViewModelProvider).userCourses; - final publicCourses = ref.watch(userViewModelProvider).publicCourses; + if (isLoading) { + // Show a loading spinner when data is being fetched + return Center(child: CircularProgressIndicator()); + } + final userCourses = ref.watch(userViewModelProvider).userCourses ?? []; + final publicCourses = ref.watch(userViewModelProvider).publicCourses ?? []; final liveStreams = ref.watch(videoViewModelProvider).liveStreams; + final liveStreamWithThumb = + ref.watch(videoViewModelProvider).liveStreamsWithThumb ?? []; bool isTablet = MediaQuery.of(context).size.width >= 600 ? true : false; return PopScope( @@ -63,15 +72,14 @@ class CourseOverviewState extends ConsumerState { onRefresh: _refreshData, child: ListView( children: [ - if (isLoggedIn) Center( - child: _buildSection( - "Live Now", - 0, - (userCourses ?? []) + (publicCourses ?? []), - liveStreams, - ), + child: LiveStreamSection( + ref: ref, + sectionTitle: "Live Now", + courses: (userCourses) + (publicCourses), + streams: liveStreamWithThumb, ), + ), if (isTablet) Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -79,7 +87,7 @@ class CourseOverviewState extends ConsumerState { Expanded( child: _buildSection( "My Courses", - 1, + SectionKind.myCourses, userCourses, liveStreams, ), @@ -87,7 +95,7 @@ class CourseOverviewState extends ConsumerState { Expanded( child: _buildSection( "Public Courses", - 2, + SectionKind.publicCourses, publicCourses, liveStreams, ), @@ -97,13 +105,13 @@ class CourseOverviewState extends ConsumerState { else ...[ _buildSection( "My Courses", - 1, + SectionKind.myCourses, userCourses, liveStreams, ), _buildSection( "Public Courses", - 2, + SectionKind.publicCourses, publicCourses, liveStreams, ), @@ -115,26 +123,32 @@ class CourseOverviewState extends ConsumerState { ); } - Widget _buildSection(String title, int sectionKind, courses, streams) { + Widget _buildSection(String title, + SectionKind sectionKind, + courses, + streams,) { return CourseSection( ref: ref, sectionTitle: title, sectionKind: sectionKind, - onViewAll: () { - switch (title) { - case "My Courses": - _navigateTo(const MyCourses()); - break; - case "Public Courses": - _navigateTo(const PublicCourses()); - break; - } - }, - courses: courses ?? [], - streams: streams ?? [], + onViewAll: () => _onViewAllPressed(title), + courses: courses, + streams: streams, ); } + void _onViewAllPressed(String title) { + switch (title) { + case "My Courses": + _navigateTo(const MyCourses()); + break; + case "Public Courses": + _navigateTo(const PublicCourses()); + break; + // Add more cases as needed + } + } + void _navigateTo(Widget page) { Navigator.push( context, @@ -150,8 +164,16 @@ class CourseOverviewState extends ConsumerState { } Future _refreshData() async { + setState( + () => isLoading = true); // Set loading to true at the start of refresh + final userViewModelNotifier = ref.read(userViewModelProvider.notifier); await userViewModelNotifier.fetchUserCourses(); await userViewModelNotifier.fetchPublicCourses(); + await ref.read(videoViewModelProvider.notifier).fetchLiveNowStreams(); + await ref.read(videoViewModelProvider.notifier).fetchLiveThumbnails(); + + setState(() => + isLoading = false); // Set loading to false once refresh is complete } } diff --git a/lib/views/course_view/list_courses_view/courses_list_view.dart b/lib/views/course_view/list_courses_view/courses_list_view.dart index 7854ffa5..26f3c6a4 100644 --- a/lib/views/course_view/list_courses_view/courses_list_view.dart +++ b/lib/views/course_view/list_courses_view/courses_list_view.dart @@ -74,12 +74,8 @@ class CoursesList extends ConsumerWidget { }, title: course.name, tumID: course.tUMOnlineIdentifier, - path: 'assets/images/course2.png', live: liveCourses.contains(course), courseId: course.id, - semester: - course.semester.teachingTerm + course.semester.year.toString(), - isCourse: true, onTap: () { Navigator.push( context, diff --git a/lib/views/course_view/pinned_courses_view/pinned_card.dart b/lib/views/course_view/pinned_courses_view/pinned_card.dart deleted file mode 100644 index 2985b10c..00000000 --- a/lib/views/course_view/pinned_courses_view/pinned_card.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gocast_mobile/base/networking/api/gocast/api_v2.pb.dart'; -import 'package:gocast_mobile/views/course_view/components/base_card.dart'; -import 'package:gocast_mobile/views/course_view/components/pin_button.dart'; - -class PinnedCourseCard extends BaseCard { - final Course course; - final bool isPinned; - final VoidCallback onPinToggle; - - const PinnedCourseCard({ - super.key, - required super.imageName, - required super.onTap, - required this.course, - required this.isPinned, - required this.onPinToggle, - }); - - @override - List buildCardContent() { - return [ - buildHeader( - title: '${course.name} - ${course.tUMOnlineIdentifier}', - subtitle: "${course.semester.year} ${course.semester.teachingTerm}", - trailing: PinButton( - // Use PinButton - courseId: course.id, - isInitiallyPinned: isPinned, - onPinStatusChanged: onPinToggle, - ), - ), - buildImage(), - ]; - } -} diff --git a/lib/views/course_view/pinned_courses_view/pinned_courses_view.dart b/lib/views/course_view/pinned_courses_view/pinned_courses_view.dart index 7b217110..82d2a899 100644 --- a/lib/views/course_view/pinned_courses_view/pinned_courses_view.dart +++ b/lib/views/course_view/pinned_courses_view/pinned_courses_view.dart @@ -98,7 +98,6 @@ class PinnedCoursesState extends ConsumerState { isPinned: isPinned, onPinUnpin: (course) => _togglePin(course, isPinned), live: liveCourses.contains(course), - isCourse: true, title: course.name, courseId: course.id, subtitle: course.tUMOnlineIdentifier,