diff --git a/CastIt.Android/lib/main.dart b/CastIt.Android/lib/main.dart index 43fd7813..0aefa903 100644 --- a/CastIt.Android/lib/main.dart +++ b/CastIt.Android/lib/main.dart @@ -78,6 +78,14 @@ class MyApp extends StatelessWidget { final settingsBloc = ctx.read(); return IntroBloc(settings, settingsBloc); }), + BlocProvider(create: (ctx) { + final serverWsBloc = ctx.read(); + return PlayedPlayListItemBloc(serverWsBloc); + }), + BlocProvider(create: (ctx) { + final serverWsBloc = ctx.read(); + return PlayedFileItemBloc(serverWsBloc); + }), ], child: BlocBuilder( builder: (ctx, state) => _buildApp(state), diff --git a/CastIt.Android/lib/presentation/playlist/playlist_page.dart b/CastIt.Android/lib/presentation/playlist/playlist_page.dart index ad121040..bc1fe92c 100644 --- a/CastIt.Android/lib/presentation/playlist/playlist_page.dart +++ b/CastIt.Android/lib/presentation/playlist/playlist_page.dart @@ -1,18 +1,16 @@ import 'package:castit/application/bloc.dart'; -import 'package:castit/domain/models/models.dart'; -import 'package:castit/generated/l10n.dart'; -import 'package:castit/presentation/shared/extensions/styles.dart'; -import 'package:castit/presentation/shared/something_went_wrong.dart'; -import 'package:collection/collection.dart' show IterableExtension; +import 'package:castit/presentation/playlist/widgets/playlist_content_disconnected.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_content_loaded.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_content_loading.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_content_notfound.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_fab.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nil/nil.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; -import 'widgets/file_item.dart'; -import 'widgets/item_counter.dart'; - class PlayListPage extends StatefulWidget { final int id; final int? scrollToFileId; @@ -23,7 +21,7 @@ class PlayListPage extends StatefulWidget { }); static Future forDetails(int playListId, int? scrollToFileId, BuildContext context) async { - context.read().add(PlayListEvent.load(id: playListId)); + context.read().add(PlayListEvent.load(id: playListId, scrollToFileId: scrollToFileId)); final route = MaterialPageRoute(builder: (_) => PlayListPage(id: playListId, scrollToFileId: scrollToFileId)); await Navigator.of(context).push(route); await route.completed; @@ -36,53 +34,72 @@ class PlayListPage extends StatefulWidget { class _PlayListPageState extends State with SingleTickerProviderStateMixin { final _refreshController = RefreshController(); - final _listViewScrollController = ScrollController(); - final _itemHeight = 75.0; - - final _searchFocusNode = FocusNode(); - - late TextEditingController _searchBoxTextController; - late AnimationController _hideFabAnimController; @override void initState() { super.initState(); - _searchBoxTextController = TextEditingController(text: ''); _hideFabAnimController = AnimationController( vsync: this, duration: kThemeAnimationDuration, value: 1, // initially visible ); - _searchBoxTextController.addListener(_onSearchTextChanged); _listViewScrollController.addListener(_onListViewScroll); } @override Widget build(BuildContext context) { - return BlocConsumer( - listener: (ctx, state) { - state.maybeMap( - loaded: (_) => _refreshController.refreshCompleted(), - disconnected: (_) => _refreshController.refreshCompleted(), - close: (_) => Navigator.of(ctx).pop(), - orElse: () {}, - ); - }, - builder: (_, state) => Scaffold( - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ..._buildPage(state), - ], + return Scaffold( + body: SafeArea( + child: BlocConsumer( + listener: (ctx, state) => state.maybeMap( + loaded: (state) { + _refreshController.refreshCompleted(); + if (state.scrollToFileId != null) { + final index = state.files.indexWhere((el) => el.id == state.scrollToFileId!); + if (index >= 0) { + SchedulerBinding.instance!.addPostFrameCallback((_) => _animateToIndex(index)); + } + } + }, + disconnected: (_) => _refreshController.refreshCompleted(), + close: (_) => Navigator.of(ctx).pop(), + orElse: () {}, ), + builder: (ctx, state) => state.map( + loading: (_) => const PlayListContentLoading(), + loaded: (state) => PlayListContentLoaded( + playListId: state.playlistId, + isLoaded: state.loaded, + files: state.isFiltering ? state.filteredFiles : state.files, + searchBoxIsVisible: state.searchBoxIsVisible, + itemHeight: _itemHeight, + refreshController: _refreshController, + listViewScrollController: _listViewScrollController, + ), + disconnected: (_) => const PlayListContentDisconnected(), + close: (_) => Container(), + notFound: (_) => const PlayListContentNotFound(), + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: BlocBuilder( + builder: (ctx, state) => state.maybeMap( + loaded: (state) => PlayListFab( + id: state.playlistId, + name: state.name, + loop: state.loop, + shuffle: state.shuffle, + hideFabAnimController: _hideFabAnimController, + //TODO: THIS WIDGET IS BEING REBUILD BECAUSE OF THE FUNCTION + onArrowTopTap: () => _animateToIndex(1), + ), + orElse: () => nil, ), - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: _buildFloatingActionBar(state), ), ); } @@ -90,248 +107,14 @@ class _PlayListPageState extends State with SingleTickerProviderSt @override void dispose() { _hideFabAnimController.dispose(); - _searchBoxTextController.dispose(); _listViewScrollController.dispose(); _refreshController.dispose(); super.dispose(); } - List _buildPage(PlayListState state) { - final i18n = S.of(context); - final theme = Theme.of(context); - return state.map>( - disconnected: (_) { - return [ - _buildHeader(), - const SomethingWentWrong(), - ]; - }, - loading: (_) { - return [ - _buildHeader(), - const Expanded( - child: Center( - child: CircularProgressIndicator(), - ), - ), - ]; - }, - loaded: (s) { - if (!s.loaded) { - return [ - _buildHeader(), - Expanded( - child: Center( - child: Text(i18n.somethingWentWrong), - ), - ), - ]; - } - if (widget.scrollToFileId != null) { - final position = s.files.firstWhereOrNull((element) => element.id == widget.scrollToFileId)?.position; - if (position != null) { - SchedulerBinding.instance!.addPostFrameCallback((_) => _animateToIndex(position)); - } - } - final filesToUse = s.isFiltering ? s.filteredFiles : s.files; - return [ - _buildHeader(itemCount: filesToUse.length, showSearch: s.searchBoxIsVisible), - _buildItems(filesToUse), - ]; - }, - close: (_) => [], - notFound: (_) => [ - _buildHeader(), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.info_outline, size: 60), - Text(i18n.playlistNotFound, textAlign: TextAlign.center, style: theme.textTheme.headline4), - ], - ), - ) - ], - ); - } - - Widget _buildHeader({int? itemCount, bool showSearch = false}) { - final i18n = S.of(context); - if (showSearch) { - WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { - _searchFocusNode.requestFocus(); - }); - } - return Container( - margin: const EdgeInsets.symmetric(vertical: 10), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - label: Text( - i18n.playlists, - style: const TextStyle(fontSize: 24), - ), - ), - ), - if (itemCount != null) - Container( - margin: const EdgeInsets.only(right: 20), - child: ItemCounter(itemCount), - ), - ], - ), - if (showSearch) _buildSearchBox(), - ], - ), - ); - } - - Widget _buildItems(List files) { - final listView = SmartRefresher( - header: const MaterialClassicHeader(), - controller: _refreshController, - onRefresh: () { - context.read().add(PlayListEvent.load(id: widget.id)); - }, - child: ListView.builder( - controller: _listViewScrollController, - shrinkWrap: true, - itemCount: files.length, - itemBuilder: (ctx, i) { - final file = files[i]; - return FileItem( - key: _getKeyForFileItem(file.id), - itemHeight: _itemHeight, - id: file.id, - position: file.position, - playListId: file.playListId, - isBeingPlayed: file.isBeingPlayed, - totalSeconds: file.totalSeconds, - name: file.filename, - path: file.path, - exists: file.exists, - isLocalFile: file.isLocalFile, - isUrlFile: file.isUrlFile, - playedPercentage: file.playedPercentage, - loop: file.loop, - subtitle: file.subTitle, - playedSeconds: file.playedSeconds, - ); - }, - ), - ); - - return Expanded(child: listView); - } - - Widget _buildSearchBox() { - final theme = Theme.of(context); - final i18n = S.of(context); - return Card( - elevation: 10, - margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - shape: Styles.floatingCardShape, - child: Row( - children: [ - Container( - margin: const EdgeInsets.only(left: 10), - child: const Icon(Icons.search, size: 30), - ), - Expanded( - child: TextField( - controller: _searchBoxTextController, - focusNode: _searchFocusNode, - cursorColor: theme.accentColor, - keyboardType: TextInputType.text, - textInputAction: TextInputAction.go, - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 15), - hintText: '${i18n.search}...', - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: _cleanSearchText, - ), - ], - ), - ); - } - - Widget? _buildFloatingActionBar(PlayListState state) { - final theme = Theme.of(context); - const iconSize = 30.0; - return state.map( - disconnected: (s) => null, - loading: (s) => null, - notFound: (_) => null, - loaded: (s) => FadeTransition( - opacity: _hideFabAnimController, - child: Card( - elevation: 10, - shape: Styles.floatingCardShape, - margin: const EdgeInsets.symmetric(horizontal: 20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ButtonBar( - buttonPadding: const EdgeInsets.all(0), - children: [ - IconButton( - icon: Icon(Icons.loop, color: s.loop ? theme.accentColor : null, size: iconSize), - onPressed: () => _setPlayListOptions(!s.loop, s.shuffle), - ), - IconButton( - icon: Icon(Icons.shuffle, color: s.shuffle ? theme.accentColor : null, size: iconSize), - onPressed: () => _setPlayListOptions(s.loop, !s.shuffle), - ), - ], - ), - Expanded( - child: Text(s.name, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis), - ), - ButtonBar( - buttonPadding: const EdgeInsets.all(0), - children: [ - IconButton( - icon: const Icon(Icons.search, size: iconSize), - onPressed: _toggleSearchBoxVisibility, - ), - IconButton( - icon: const Icon(Icons.arrow_upward, size: iconSize), - onPressed: () => _animateToIndex(1), - ), - ], - ) - ], - ), - ), - ), - ), - close: (s) => null, - ); - } - - void _setPlayListOptions(bool loop, bool shuffle) { - final bloc = context.read(); - bloc.setPlayListOptions(widget.id, loop: loop, shuffle: shuffle); - context.read().add(PlayListEvent.playListOptionsChanged(loop: loop, shuffle: shuffle)); - } - - void _animateToIndex(int i) { - final offset = (_itemHeight * i) - _itemHeight; - _listViewScrollController.animateTo(offset, duration: const Duration(seconds: 2), curve: Curves.fastOutSlowIn); + Future _animateToIndex(int i) async { + final offset = _itemHeight * i; + await _listViewScrollController.animateTo(offset, duration: const Duration(seconds: 2), curve: Curves.fastOutSlowIn); } void _onListViewScroll() { @@ -346,18 +129,4 @@ class _PlayListPageState extends State with SingleTickerProviderSt break; } } - - void _toggleSearchBoxVisibility() => context.read().add(const PlayListEvent.toggleSearchBoxVisibility()); - - void _onSearchTextChanged() => context.read().add(PlayListEvent.searchBoxTextChanged(text: _searchBoxTextController.text)); - - void _cleanSearchText() { - if (_searchBoxTextController.text.isEmpty) { - _toggleSearchBoxVisibility(); - return; - } - _searchBoxTextController.text = ''; - } - - Key _getKeyForFileItem(int id) => Key('file_item_$id'); } diff --git a/CastIt.Android/lib/presentation/playlist/widgets/file_item.dart b/CastIt.Android/lib/presentation/playlist/widgets/file_item.dart index 4e80e4ab..73679a64 100644 --- a/CastIt.Android/lib/presentation/playlist/widgets/file_item.dart +++ b/CastIt.Android/lib/presentation/playlist/widgets/file_item.dart @@ -1,5 +1,5 @@ import 'package:castit/application/bloc.dart'; -import 'package:castit/domain/extensions/duration_extensions.dart'; +import 'package:castit/domain/models/models.dart'; import 'package:castit/presentation/shared/extensions/styles.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -23,28 +23,32 @@ class FileItem extends StatelessWidget { final double itemHeight; final String subtitle; final double playedSeconds; + final String fullTotalDuration; - const FileItem({ - required this.position, - required this.id, - required this.playListId, - required this.totalSeconds, - required this.isBeingPlayed, - required this.name, - required this.path, - required this.playedPercentage, - required this.exists, - required this.isLocalFile, - required this.isUrlFile, - required this.loop, + FileItem.fromItem({ required this.itemHeight, - required this.subtitle, - required this.playedSeconds, + required FileItemResponseDto file, Key? key, - }) : super(key: key); + }) : id = file.id, + position = file.position, + playListId = file.playListId, + isBeingPlayed = file.isBeingPlayed, + totalSeconds = file.totalSeconds, + name = file.filename, + path = file.path, + exists = file.exists, + isLocalFile = file.isLocalFile, + isUrlFile = file.isUrlFile, + playedPercentage = file.playedPercentage, + loop = file.loop, + subtitle = file.subTitle, + playedSeconds = file.playedSeconds, + fullTotalDuration = file.fullTotalDuration, + super(key: key); @override Widget build(BuildContext context) { + // print('Building item = $id'); final theme = Theme.of(context); final title = !loop ? Text( @@ -72,8 +76,6 @@ class FileItem extends StatelessWidget { ], ); - final playedDurationText = playedSeconds >= 0 ? Duration(seconds: playedSeconds.round()).formatDuration() : 'N/A'; - final totalDurationText = totalSeconds > 0 ? Duration(seconds: totalSeconds.round()).formatDuration() : 'N/A'; return Container( color: isBeingPlayed ? theme.accentColor.withOpacity(0.5) : null, height: itemHeight, @@ -91,7 +93,15 @@ class FileItem extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(subtitle, overflow: TextOverflow.ellipsis), - Text('$playedDurationText / $totalDurationText', overflow: TextOverflow.ellipsis), + BlocBuilder( + builder: (ctx, state) => Text( + state.maybeMap( + playing: (state) => state.id == id && state.playListId == playListId ? state.fullTotalDuration : fullTotalDuration, + orElse: () => fullTotalDuration, + ), + overflow: TextOverflow.ellipsis, + ), + ), ], ), Container( @@ -105,12 +115,17 @@ class FileItem extends StatelessWidget { thumbColor: Colors.transparent, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 0.0, disabledThumbRadius: 0.0), ), - child: Slider( - value: playedPercentage, - max: 100, - activeColor: Colors.black, - inactiveColor: Colors.grey, - onChanged: null, + child: BlocBuilder( + builder: (ctx, state) => Slider( + value: state.maybeMap( + playing: (state) => state.id == id && state.playListId == playListId ? state.playedPercentage : playedPercentage, + orElse: () => playedPercentage, + ), + max: 100, + activeColor: Colors.black, + inactiveColor: Colors.grey, + onChanged: null, + ), ), ), ), diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_disconnected.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_disconnected.dart new file mode 100644 index 00000000..cc9c570e --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_disconnected.dart @@ -0,0 +1,18 @@ +import 'package:castit/presentation/playlist/widgets/playlist_header.dart'; +import 'package:castit/presentation/shared/something_went_wrong.dart'; +import 'package:flutter/material.dart'; + +class PlayListContentDisconnected extends StatelessWidget { + const PlayListContentDisconnected({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + PlayListHeader(showSearch: false), + SomethingWentWrong(), + ], + ); + } +} diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loaded.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loaded.dart new file mode 100644 index 00000000..ab16cbcb --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loaded.dart @@ -0,0 +1,68 @@ +import 'package:castit/application/bloc.dart'; +import 'package:castit/domain/models/models.dart'; +import 'package:castit/generated/l10n.dart'; +import 'package:castit/presentation/playlist/widgets/file_item.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class PlayListContentLoaded extends StatelessWidget { + final int playListId; + final bool isLoaded; + final List files; + final bool searchBoxIsVisible; + final double itemHeight; + final RefreshController refreshController; + final ScrollController listViewScrollController; + + const PlayListContentLoaded({ + Key? key, + required this.playListId, + required this.isLoaded, + required this.files, + required this.searchBoxIsVisible, + required this.itemHeight, + required this.refreshController, + required this.listViewScrollController, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + + if (!isLoaded) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const PlayListHeader(showSearch: false), + Expanded( + child: Center( + child: Text(s.somethingWentWrong), + ), + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PlayListHeader(itemCount: files.length, showSearch: searchBoxIsVisible), + Expanded( + child: SmartRefresher( + header: const MaterialClassicHeader(), + controller: refreshController, + onRefresh: () => context.read().add(PlayListEvent.load(id: playListId)), + child: ListView.builder( + controller: listViewScrollController, + shrinkWrap: true, + itemCount: files.length, + itemBuilder: (ctx, i) => FileItem.fromItem(key: Key('file_item_$i'), itemHeight: itemHeight, file: files[i]), + ), + ), + ), + ], + ); + } +} diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loading.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loading.dart new file mode 100644 index 00000000..91cbad1b --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_loading.dart @@ -0,0 +1,21 @@ +import 'package:castit/presentation/playlist/widgets/playlist_header.dart'; +import 'package:flutter/material.dart'; + +class PlayListContentLoading extends StatelessWidget { + const PlayListContentLoading({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + PlayListHeader(showSearch: false), + Expanded( + child: Center( + child: CircularProgressIndicator(), + ), + ), + ], + ); + } +} diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_notfound.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_notfound.dart new file mode 100644 index 00000000..a9e18d67 --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_content_notfound.dart @@ -0,0 +1,31 @@ +import 'package:castit/generated/l10n.dart'; +import 'package:castit/presentation/playlist/widgets/playlist_header.dart'; +import 'package:flutter/material.dart'; + +class PlayListContentNotFound extends StatelessWidget { + const PlayListContentNotFound({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final theme = Theme.of(context); + return Column( + children: [ + const PlayListHeader(showSearch: false), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.info_outline, size: 60), + Text( + s.playlistNotFound, + textAlign: TextAlign.center, + style: theme.textTheme.headline4, + ), + ], + ), + ) + ], + ); + } +} diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_fab.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_fab.dart new file mode 100644 index 00000000..5eefc9c5 --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_fab.dart @@ -0,0 +1,83 @@ +import 'package:castit/application/bloc.dart'; +import 'package:castit/presentation/shared/extensions/styles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PlayListFab extends StatelessWidget { + final int id; + final String name; + final bool loop; + final bool shuffle; + final Function onArrowTopTap; + final AnimationController hideFabAnimController; + + const PlayListFab({ + Key? key, + required this.id, + required this.name, + required this.loop, + required this.shuffle, + required this.onArrowTopTap, + required this.hideFabAnimController, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const iconSize = 30.0; + print('fab'); + return FadeTransition( + opacity: hideFabAnimController, + child: Card( + elevation: 10, + shape: Styles.floatingCardShape, + margin: const EdgeInsets.symmetric(horizontal: 20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ButtonBar( + buttonPadding: const EdgeInsets.all(0), + children: [ + IconButton( + icon: Icon(Icons.loop, color: loop ? theme.accentColor : null, size: iconSize), + onPressed: () => _setPlayListOptions(!loop, shuffle, context), + ), + IconButton( + icon: Icon(Icons.shuffle, color: shuffle ? theme.accentColor : null, size: iconSize), + onPressed: () => _setPlayListOptions(loop, !shuffle, context), + ), + ], + ), + Expanded( + child: Text(name, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis), + ), + ButtonBar( + buttonPadding: const EdgeInsets.all(0), + children: [ + IconButton( + icon: const Icon(Icons.search, size: iconSize), + onPressed: () => _toggleSearchBoxVisibility(context), + ), + IconButton( + icon: const Icon(Icons.arrow_upward, size: iconSize), + onPressed: () => onArrowTopTap(), + ), + ], + ) + ], + ), + ), + ), + ); + } + + void _setPlayListOptions(bool loop, bool shuffle, BuildContext context) { + final bloc = context.read(); + bloc.setPlayListOptions(id, loop: loop, shuffle: shuffle); + context.read().add(PlayListEvent.playListOptionsChanged(loop: loop, shuffle: shuffle)); + } + + void _toggleSearchBoxVisibility(BuildContext context) => context.read().add(const PlayListEvent.toggleSearchBoxVisibility()); +} diff --git a/CastIt.Android/lib/presentation/playlist/widgets/playlist_header.dart b/CastIt.Android/lib/presentation/playlist/widgets/playlist_header.dart new file mode 100644 index 00000000..bd2df6af --- /dev/null +++ b/CastIt.Android/lib/presentation/playlist/widgets/playlist_header.dart @@ -0,0 +1,115 @@ +import 'package:castit/application/bloc.dart'; +import 'package:castit/generated/l10n.dart'; +import 'package:castit/presentation/shared/extensions/styles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'item_counter.dart'; + +class PlayListHeader extends StatefulWidget { + final bool showSearch; + final int? itemCount; + + const PlayListHeader({ + Key? key, + required this.showSearch, + this.itemCount, + }) : super(key: key); + + @override + _PlayListHeaderState createState() => _PlayListHeaderState(); +} + +class _PlayListHeaderState extends State { + final _searchFocusNode = FocusNode(); + late TextEditingController _searchBoxTextController; + + @override + void initState() { + super.initState(); + + _searchBoxTextController = TextEditingController(text: ''); + _searchBoxTextController.addListener(_onSearchTextChanged); + } + + @override + Widget build(BuildContext context) { + final i18n = S.of(context); + final theme = Theme.of(context); + if (widget.showSearch) { + WidgetsBinding.instance!.addPostFrameCallback((timeStamp) => _searchFocusNode.requestFocus()); + } + return Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + label: Text( + i18n.playlists, + style: const TextStyle(fontSize: 24), + ), + ), + ), + if (widget.itemCount != null) + Container( + margin: const EdgeInsets.only(right: 20), + child: ItemCounter(widget.itemCount!), + ), + ], + ), + if (widget.showSearch) + Card( + elevation: 10, + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: Styles.floatingCardShape, + child: Row( + children: [ + Container( + margin: const EdgeInsets.only(left: 10), + child: const Icon(Icons.search, size: 30), + ), + Expanded( + child: TextField( + controller: _searchBoxTextController, + focusNode: _searchFocusNode, + cursorColor: theme.accentColor, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.go, + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 15), + hintText: '${i18n.search}...', + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _cleanSearchText, + ), + ], + ), + ), + ], + ), + ); + } + + void _onSearchTextChanged() => context.read().add(PlayListEvent.searchBoxTextChanged(text: _searchBoxTextController.text)); + + void _cleanSearchText() { + if (_searchBoxTextController.text.isEmpty) { + _toggleSearchBoxVisibility(); + return; + } + _searchBoxTextController.text = ''; + } + + void _toggleSearchBoxVisibility() => context.read().add(const PlayListEvent.toggleSearchBoxVisibility()); +} diff --git a/CastIt.Android/lib/presentation/playlists/playlists_page.dart b/CastIt.Android/lib/presentation/playlists/playlists_page.dart index 80a67af1..6b332516 100644 --- a/CastIt.Android/lib/presentation/playlists/playlists_page.dart +++ b/CastIt.Android/lib/presentation/playlists/playlists_page.dart @@ -26,69 +26,48 @@ class _PlayListsPageState extends State with AutomaticKeepAliveCl return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PageHeader( - title: i18n.playlists, - icon: Icons.library_music, - ), + PageHeader(title: i18n.playlists, icon: Icons.library_music), Expanded( child: BlocConsumer( - listener: (ctx, state) { - state.maybeMap( - loaded: (_) { - _refreshController.refreshCompleted(); - }, - disconnected: (_) { - _refreshController.refreshCompleted(); - }, - orElse: () {}, - ); - }, + listener: (ctx, state) => state.maybeMap( + loaded: (_) => _refreshController.refreshCompleted(), + disconnected: (_) => _refreshController.refreshCompleted(), + orElse: () {}, + ), builder: (ctx, state) => SmartRefresher( header: const MaterialClassicHeader(), controller: _refreshController, - onRefresh: () { - context.read().add(PlayListsEvent.load()); - }, - child: _buildPage(ctx, state), + onRefresh: () => context.read().add(PlayListsEvent.load()), + child: state.when( + loading: () => const Center(child: CircularProgressIndicator()), + loaded: (playlists, _) { + //TODO: CREATE A WAY TO FORCE A RECONNECT + // context.read().add(ServerWsEvent.connectToWs()); + return ListView.builder( + itemCount: playlists.length, + itemBuilder: (ctx, i) { + final playlist = playlists[i]; + return PlayListItem( + key: Key('playlist_$i'), + id: playlist.id, + name: playlist.name, + numberOfFiles: playlist.numberOfFiles, + loop: playlist.loop, + shuffle: playlist.shuffle, + totalDuration: playlist.totalDuration, + ); + }, + ); + }, + disconnected: () { + context.read().add(ServerWsEvent.disconnectedFromWs()); + return const SomethingWentWrong(); + }, + ), ), ), ), ], ); } - - Widget _buildPage(BuildContext context, PlayListsState state) { - return state.when( - loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, - loaded: (playlists, _) { - //TODO: CREATE A WAY TO FORCE A RECONNECT - // context.read().add(ServerWsEvent.connectToWs()); - return ListView.builder( - itemCount: playlists.length, - itemBuilder: (ctx, i) { - final playlist = playlists[i]; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - child: PlayListItem( - id: playlist.id, - name: playlist.name, - numberOfFiles: playlist.numberOfFiles, - loop: playlist.loop, - shuffle: playlist.shuffle, - totalDuration: playlist.totalDuration, - ), - ); - }, - ); - }, - disconnected: () { - context.read().add(ServerWsEvent.disconnectedFromWs()); - return const SomethingWentWrong(); - }, - ); - } } diff --git a/CastIt.Android/lib/presentation/playlists/widgets/playlist_item.dart b/CastIt.Android/lib/presentation/playlists/widgets/playlist_item.dart index 28147594..601f0ff5 100644 --- a/CastIt.Android/lib/presentation/playlists/widgets/playlist_item.dart +++ b/CastIt.Android/lib/presentation/playlists/widgets/playlist_item.dart @@ -1,9 +1,11 @@ +import 'package:castit/application/bloc.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import 'playlist_options_bottom_sheet_dialog.dart'; import '../../playlist/playlist_page.dart'; import '../../playlist/widgets/item_counter.dart'; import '../../shared/extensions/styles.dart'; +import 'playlist_options_bottom_sheet_dialog.dart'; class PlayListItem extends StatelessWidget { final int id; @@ -20,27 +22,39 @@ class PlayListItem extends StatelessWidget { required this.loop, required this.shuffle, required this.totalDuration, - }); + Key? key, + }) : super(key: key); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ListTile( - leading: const Icon(Icons.list, size: 36), - title: Text( - name, - style: theme.textTheme.headline6, - overflow: TextOverflow.ellipsis, - ), - subtitle: Row( - children: [ - const Icon(Icons.hourglass_empty, size: 18), - Text(totalDuration, overflow: TextOverflow.ellipsis), - ], + return Card( + margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: ListTile( + leading: const Icon(Icons.list, size: 36), + title: Text( + name, + style: theme.textTheme.headline6, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + const Icon(Icons.hourglass_empty, size: 18), + BlocBuilder( + builder: (ctx, state) => Text( + state.maybeMap( + playing: (state) => state.id == id ? state.totalDuration : totalDuration, + orElse: () => totalDuration, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + trailing: ItemCounter(numberOfFiles), + onLongPress: () => _showPlayListOptionsModal(context), + onTap: () => _goToPlayListPage(context), ), - trailing: ItemCounter(numberOfFiles), - onLongPress: () => _showPlayListOptionsModal(context), - onTap: () => _goToPlayListPage(context), ); }