// Copyright 2020 Ben Hills and the project contributors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; import 'package:pinepods_mobile/l10n/L.dart'; import 'package:pinepods_mobile/state/queue_event_state.dart'; import 'package:pinepods_mobile/ui/podcast/transcript_view.dart'; import 'package:pinepods_mobile/ui/podcast/up_next_view.dart'; import 'package:pinepods_mobile/ui/podcast/pinepods_up_next_view.dart'; import 'package:pinepods_mobile/ui/widgets/slider_handle.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; /// This class gives us options that can be dragged up from the bottom of the main player /// window. /// /// Currently these options are Up Next & Transcript. /// /// This class is an initial version and should by much simpler than it is; however, /// a [NestedScrollView] is the widget we need to implement this UI, there is a current /// issue whereby the scroll view and [DraggableScrollableSheet] clash and therefore cannot /// be used together. /// /// See issues [64157](https://github.com/flutter/flutter/issues/64157) /// [67219](https://github.com/flutter/flutter/issues/67219) /// /// If anyone can come up with a more elegant solution (and one that does not throw /// an overflow error in debug) please raise and issue/submit a PR. /// class NowPlayingOptionsSelector extends StatefulWidget { final double? scrollPos; static const baseSize = 68.0; const NowPlayingOptionsSelector({super.key, this.scrollPos}); @override State createState() => _NowPlayingOptionsSelectorState(); } class _NowPlayingOptionsSelectorState extends State { DraggableScrollableController? draggableController; @override Widget build(BuildContext context) { final queueBloc = Provider.of(context, listen: false); final theme = Theme.of(context); final windowHeight = MediaQuery.of(context).size.height; final minSize = NowPlayingOptionsSelector.baseSize / (windowHeight - NowPlayingOptionsSelector.baseSize); return DraggableScrollableSheet( initialChildSize: minSize, minChildSize: minSize, maxChildSize: 1.0, controller: draggableController, // Snap doesn't work as the sheet and scroll controller just don't get along // snap: true, // snapSizes: [minSize, maxSize], builder: (BuildContext context, ScrollController scrollController) { return StreamBuilder( initialData: QueueEmptyState(), stream: queueBloc.queue, builder: (context, queueSnapshot) { final hasTranscript = queueSnapshot.hasData && queueSnapshot.data?.playing != null && queueSnapshot.data!.playing!.hasTranscripts; return DefaultTabController( animationDuration: !draggableController!.isAttached || draggableController!.size <= minSize ? const Duration(seconds: 0) : kTabScrollDuration, length: hasTranscript ? 2 : 1, child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) { return SingleChildScrollView( controller: scrollController, child: ConstrainedBox( constraints: BoxConstraints.expand( height: constraints.maxHeight, ), child: Material( color: theme.secondaryHeaderColor, shape: RoundedRectangleBorder( side: BorderSide( color: Theme.of(context).highlightColor, width: 0.0, ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(18.0), topRight: Radius.circular(18.0), ), ), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ SliderHandle( label: optionsSliderOpen() ? L.of(context)!.semantic_playing_options_collapse_label : L.of(context)!.semantic_playing_options_expand_label, onTap: () { if (draggableController != null) { if (draggableController!.size < 1.0) { draggableController!.animateTo( 1.0, duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, ); } else { draggableController!.animateTo( 0.0, duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, ); } } }, ), DecoratedBox( decoration: BoxDecoration( color: Colors.white.withOpacity(0.0), border: Border( bottom: draggableController != null && (!draggableController!.isAttached || draggableController!.size <= minSize) ? BorderSide.none : BorderSide(color: Colors.grey[800]!, width: 1.0), ), ), child: TabBar( onTap: (index) { DefaultTabController.of(ctx).animateTo(index); if (draggableController != null && draggableController!.size < 1.0) { draggableController!.animateTo( 1.0, duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, ); } }, automaticIndicatorColorAdjustment: false, indicatorPadding: EdgeInsets.zero, /// Little hack to hide the indicator when closed indicatorColor: draggableController != null && (!draggableController!.isAttached || draggableController!.size <= minSize) ? Theme.of(context).secondaryHeaderColor : null, tabs: [ Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), child: Text( L.of(context)!.up_next_queue_label.toUpperCase(), style: Theme.of(context).textTheme.labelLarge, ), ), if (hasTranscript) Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), child: Text( L.of(context)!.transcript_label.toUpperCase(), style: Theme.of(context).textTheme.labelLarge, ), ), ], ), ), const Padding(padding: EdgeInsets.only(bottom: 12.0)), Expanded( child: Consumer( builder: (context, settingsBloc, child) { final settings = settingsBloc.currentSettings; final isPinepodsConnected = settings.pinepodsServer != null && settings.pinepodsApiKey != null && settings.pinepodsUserId != null; return TabBarView( children: [ isPinepodsConnected ? const PinepodsUpNextView() : const UpNextView(), if (hasTranscript) const TranscriptView(), ], ); }, ), ), ], ), ), ), ); }), ); }); }, ); } bool optionsSliderOpen() { return (draggableController != null && draggableController!.isAttached && draggableController!.size == 1.0); } @override void initState() { draggableController = DraggableScrollableController(); super.initState(); } } class NowPlayingOptionsScaffold extends StatelessWidget { const NowPlayingOptionsScaffold({super.key}); @override Widget build(BuildContext context) { return const SizedBox( height: NowPlayingOptionsSelector.baseSize - 8.0, ); } } /// This implementation displays the additional options in a tab set outside of a /// draggable sheet. /// /// Currently these options are Up Next & Transcript. class NowPlayingOptionsSelectorWide extends StatefulWidget { final double? scrollPos; static const baseSize = 68.0; const NowPlayingOptionsSelectorWide({super.key, this.scrollPos}); @override State createState() => _NowPlayingOptionsSelectorWideState(); } class _NowPlayingOptionsSelectorWideState extends State { DraggableScrollableController? draggableController; @override Widget build(BuildContext context) { final queueBloc = Provider.of(context, listen: false); final theme = Theme.of(context); final scrollController = ScrollController(); return StreamBuilder( initialData: QueueEmptyState(), stream: queueBloc.queue, builder: (context, queueSnapshot) { final hasTranscript = queueSnapshot.hasData && queueSnapshot.data?.playing != null && queueSnapshot.data!.playing!.hasTranscripts; return DefaultTabController( length: hasTranscript ? 2 : 1, child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) { return SingleChildScrollView( controller: scrollController, child: ConstrainedBox( constraints: BoxConstraints.expand( height: constraints.maxHeight, ), child: Material( color: theme.secondaryHeaderColor, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ DecoratedBox( decoration: BoxDecoration( color: Colors.white.withOpacity(0.0), border: Border( bottom: BorderSide(color: Colors.grey[800]!, width: 1.0), ), ), child: TabBar( automaticIndicatorColorAdjustment: false, tabs: [ Padding( padding: const EdgeInsets.only(top: 16.0, bottom: 16.0), child: Text( L.of(context)!.up_next_queue_label.toUpperCase(), style: Theme.of(context).textTheme.labelLarge, ), ), if (hasTranscript) Padding( padding: const EdgeInsets.only(top: 16.0, bottom: 16.0), child: Text( L.of(context)!.transcript_label.toUpperCase(), style: Theme.of(context).textTheme.labelLarge, ), ), ], ), ), Expanded( child: TabBarView( children: [ const UpNextView(), if (hasTranscript) const TranscriptView(), ], ), ), ], ), ), ), ); }), ); }); } }