diff --git a/firka_wear/lib/ui/components/firka_shadow.dart b/firka_wear/lib/ui/components/firka_shadow.dart new file mode 100644 index 00000000..9818a0ef --- /dev/null +++ b/firka_wear/lib/ui/components/firka_shadow.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:firka_wear/ui/theme/style.dart'; + +class FirkaShadow extends StatelessWidget { + final Widget child; + final bool shadow; + final double radius; + + const FirkaShadow({ + required this.shadow, + required this.child, + this.radius = 16.0, + super.key, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + + final shadowBox = BoxDecoration( + color: Colors.transparent, + shape: BoxShape.rectangle, + boxShadow: [ + BoxShadow( + color: wearStyle.colors.shadowColor, + spreadRadius: -4, + blurRadius: 0, + offset: Offset(0, 2), + ), + ], + borderRadius: borderRadius, + ); + + if (!shadow) { + return ClipRRect(borderRadius: borderRadius, child: child); + } + + return Container( + decoration: shadowBox, + child: ClipRRect(borderRadius: borderRadius, child: child), + ); + } +} diff --git a/firka_wear/lib/ui/wear/screens/home/home_screen.dart b/firka_wear/lib/ui/wear/screens/home/home_screen.dart index d16ce240..ac85b107 100644 --- a/firka_wear/lib/ui/wear/screens/home/home_screen.dart +++ b/firka_wear/lib/ui/wear/screens/home/home_screen.dart @@ -18,6 +18,7 @@ import 'package:firka_wear/core/extensions.dart'; import 'package:firka_wear/l10n/app_localizations.dart'; import 'package:firka_wear/ui/theme/style.dart'; import 'package:firka_wear/ui/shared/class_icon.dart'; +import 'package:firka_wear/ui/wear/widgets/lesson_card_small.dart'; import 'package:firka_wear/ui/wear/widgets/circular_progress_indicator.dart'; part 'home_screen_body.dart'; @@ -48,6 +49,9 @@ class _WearHomeScreenState extends State { late final PageController _pageController; bool disposed = false; + DateTime? _anchorLessonStart; + int _bodyPageIndex = 0; + int? _activeLessonNo; @override void didChangeDependencies() { @@ -58,7 +62,7 @@ class _WearHomeScreenState extends State { @override void initState() { super.initState(); - _pageController = PageController(initialPage: 2); + _pageController = PageController(); now = timeNow(); today = data.syncStore.getLessonsForDate(now); init = data.syncStore.timetable.isNotEmpty; @@ -133,7 +137,7 @@ class _WearHomeScreenState extends State { ) { var body = List.empty(growable: true); if (!init) { - return (body, 255.h, null); + return (body, 0.h, null); } if (today.isEmpty && @@ -148,7 +152,7 @@ class _WearHomeScreenState extends State { textAlign: TextAlign.center, ), ); - return (body, 255.h, null); + return (body, 50.h, null); } if (today.isEmpty) { body.add( @@ -162,7 +166,7 @@ class _WearHomeScreenState extends State { ); platform.invokeMethod('activity_cancel'); - return (body, 255.h, null); + return (body, 50.h, null); } if (now.isAfter(today.last.end)) { body.add( @@ -176,7 +180,7 @@ class _WearHomeScreenState extends State { ); platform.invokeMethod('activity_cancel'); - return (body, 300.h, null); + return (body, 50.h, null); } if (now.isBefore(today.first.start)) { var untilFirst = today.first.start.difference(now); @@ -192,7 +196,7 @@ class _WearHomeScreenState extends State { ); platform.invokeMethod('activity_update'); - return (body, 255.h, null); + return (body, 50.h, null); } currentLessonNo = null; if (now.isAfter(today.first.start) && now.isBefore(today.last.end)) { @@ -418,34 +422,165 @@ class _WearHomeScreenState extends State { }); } + final hasScrollableLessons = + today.isNotEmpty && + !now.isBefore(today.first.start) && + !now.isAfter(today.last.end); + + if (!hasScrollableLessons) { + return SizedBox( + height: viewportHeight, + child: _HomeScreenBodyPage( + body: body, + padding: padding, + viewportHeight: viewportHeight, + ), + ); + } + + final anchorLesson = + today.getCurrentLesson(now) ?? + today.getNextLesson(now) ?? + (today.isNotEmpty ? today.first : null); + final anchorIndex = anchorLesson == null + ? 0 + : today.indexWhere( + (e) => + e.start.millisecondsSinceEpoch == + anchorLesson.start.millisecondsSinceEpoch, + ); + final safeAnchorIndex = anchorIndex < 0 + ? 0 + : anchorIndex.clamp(0, today.length); + + final beforeLessons = today + .take(safeAnchorIndex) + .toList(growable: false); + final afterLessons = today + .skip(safeAnchorIndex + 1) + .toList(growable: false); + + final pages = [ + ...beforeLessons.map( + (lesson) => _LessonCardPage( + key: ValueKey( + 'before_${lesson.start.millisecondsSinceEpoch}', + ), + lesson: lesson, + viewportHeight: viewportHeight, + ), + ), + _HomeScreenBodyPage( + body: body, + padding: padding, + viewportHeight: viewportHeight, + ), + ...afterLessons.map( + (lesson) => _LessonCardPage( + key: ValueKey( + 'after_${lesson.start.millisecondsSinceEpoch}', + ), + lesson: lesson, + viewportHeight: viewportHeight, + ), + ), + ]; + + final newBodyPageIndex = beforeLessons.length; + final anchorStart = anchorLesson?.start; + final currentPage = _pageController.hasClients + ? _pageController.page + : null; + final shouldRetainBody = + currentPage == null || + currentPage.round() == _bodyPageIndex; + + int? pageIndexForLessonStart(DateTime start) { + final beforeIndex = beforeLessons.indexWhere( + (e) => + e.start.millisecondsSinceEpoch == + start.millisecondsSinceEpoch, + ); + if (beforeIndex != -1) { + return beforeIndex; + } + + final afterIndex = afterLessons.indexWhere( + (e) => + e.start.millisecondsSinceEpoch == + start.millisecondsSinceEpoch, + ); + if (afterIndex != -1) { + return beforeLessons.length + 1 + afterIndex; + } + + return null; + } + + DateTime? visibleLessonStartForPageIndex( + int pageIndex, + ) { + if (pageIndex == newBodyPageIndex) return null; + if (pageIndex < newBodyPageIndex) { + final beforeIndex = pageIndex; + if (beforeIndex < 0 || + beforeIndex >= beforeLessons.length) { + return null; + } + return beforeLessons[beforeIndex].start; + } + + final afterIndex = pageIndex - newBodyPageIndex - 1; + if (afterIndex < 0 || + afterIndex >= afterLessons.length) { + return null; + } + return afterLessons[afterIndex].start; + } + + final activeLessonNo = currentLessonNo; + final activeLessonChanged = + activeLessonNo != _activeLessonNo; + + final pageIndex = currentPage?.round(); + final visibleLessonStart = pageIndex == null + ? null + : visibleLessonStartForPageIndex(pageIndex); + final targetPageIndex = shouldRetainBody + ? newBodyPageIndex + : (visibleLessonStart == null + ? null + : pageIndexForLessonStart( + visibleLessonStart, + )); + + if (anchorStart != _anchorLessonStart || + newBodyPageIndex != _bodyPageIndex || + activeLessonChanged) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (anchorStart != _anchorLessonStart) { + _anchorLessonStart = anchorStart; + } + if (newBodyPageIndex != _bodyPageIndex) { + _bodyPageIndex = newBodyPageIndex; + } + if (activeLessonChanged) { + _activeLessonNo = activeLessonNo; + } + if (_pageController.hasClients && + targetPageIndex != null) { + _pageController.jumpToPage(targetPageIndex); + } + }); + } + return SizedBox( height: viewportHeight, child: PageView( controller: _pageController, scrollDirection: Axis.vertical, - children: [ - _PlaceholderPage( - index: 1, - viewportHeight: viewportHeight, - ), - _PlaceholderPage( - index: 2, - viewportHeight: viewportHeight, - ), - _HomeScreenBodyPage( - body: body, - padding: padding, - viewportHeight: viewportHeight, - ), - _PlaceholderPage( - index: 3, - viewportHeight: viewportHeight, - ), - _PlaceholderPage( - index: 4, - viewportHeight: viewportHeight, - ), - ], + children: pages, ), ); }, diff --git a/firka_wear/lib/ui/wear/screens/home/home_screen_body.dart b/firka_wear/lib/ui/wear/screens/home/home_screen_body.dart index 7fdf72e8..3d69db00 100644 --- a/firka_wear/lib/ui/wear/screens/home/home_screen_body.dart +++ b/firka_wear/lib/ui/wear/screens/home/home_screen_body.dart @@ -26,25 +26,28 @@ class _HomeScreenBodyPage extends StatelessWidget { } } -class _PlaceholderPage extends StatelessWidget { - final int index; +class _LessonCardPage extends StatelessWidget { + final Lesson? lesson; final double viewportHeight; - const _PlaceholderPage({required this.index, required this.viewportHeight}); + const _LessonCardPage({ + required this.lesson, + required this.viewportHeight, + super.key, + }); @override Widget build(BuildContext context) { + if (lesson == null) { + return SizedBox(height: viewportHeight); + } + return SizedBox( height: viewportHeight, child: Center( - child: Text( - 'Placeholder $index', - style: TextStyle( - color: wearStyle.colors.textPrimary, - fontSize: 14, - fontFamily: 'Montserrat', - fontVariations: [FontVariation('wght', 400)], - ), + child: SizedBox( + width: 340.w, + child: LessonCardSmall.fromLesson(lesson!), ), ), ); diff --git a/firka_wear/lib/ui/wear/widgets/lesson_card_small.dart b/firka_wear/lib/ui/wear/widgets/lesson_card_small.dart new file mode 100644 index 00000000..a1010d0a --- /dev/null +++ b/firka_wear/lib/ui/wear/widgets/lesson_card_small.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:kreta_api/kreta_api.dart'; + +import 'package:firka_wear/ui/components/firka_shadow.dart'; +import 'package:firka_wear/ui/shared/class_icon.dart'; +import 'package:firka_wear/ui/theme/style.dart'; + +class LessonCardSmall extends StatelessWidget { + final String uid; + final String subjectName; + final String category; + final String? roomName; + final VoidCallback? onTap; + final Color? iconColor; + final bool shadow; + + const LessonCardSmall({ + required this.uid, + required this.subjectName, + required this.category, + this.roomName, + this.onTap, + this.iconColor, + this.shadow = true, + super.key, + }); + + factory LessonCardSmall.fromLesson( + Lesson lesson, { + VoidCallback? onTap, + Color? iconColor, + bool shadow = true, + Key? key, + }) { + return LessonCardSmall( + uid: lesson.uid, + subjectName: lesson.name, + category: lesson.subject?.name ?? '', + roomName: lesson.roomName, + onTap: onTap, + iconColor: iconColor, + shadow: shadow, + key: key, + ); + } + + @override + Widget build(BuildContext context) { + final radius = 16.0; + + final content = Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + height: 24, + child: Center( + child: ClassIconWidget( + uid: uid, + className: subjectName, + category: category, + color: iconColor ?? wearStyle.colors.accent, + size: 16, + ), + ), + ), + const SizedBox(width: 4), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subjectName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: wearStyle.fonts.H_14px + .copyWith(height: 1.3) + .apply(color: wearStyle.colors.textPrimary), + ), + Text( + roomName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: wearStyle.fonts.B_12R + .copyWith(height: 1.3) + .apply(color: wearStyle.colors.textTertiary), + ), + ], + ), + ), + ], + ), + ); + + final child = onTap == null + ? content + : InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(radius), + child: content, + ); + + return SizedBox( + width: double.infinity, + child: FirkaShadow( + shadow: shadow, + radius: radius, + child: Card( + elevation: 0, + shadowColor: Colors.transparent, + color: wearStyle.colors.card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + ), + child: child, + ), + ), + ); + } +}