From aa882a13aeb3037cff60ca7aa638c4212ae7a290 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 28 May 2023 18:28:04 +0200 Subject: [PATCH] Refactor audio-message error handling --- lib/widgets/friends/friends_list.dart | 2 +- .../messages/message_audio_player.dart | 312 +++++++-------- lib/widgets/messages/messages_list.dart | 359 +++++++++--------- 3 files changed, 340 insertions(+), 333 deletions(-) diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index d9e3dad..2c70ba0 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -252,7 +252,7 @@ class _FriendsListState extends State { friends.sort((a, b) => a.username.length.compareTo(b.username.length)); } return ListView.builder( - physics: const BouncingScrollPhysics(), + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), itemCount: friends.length, itemBuilder: (context, index) { final friend = friends[index]; diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 67245aa..34c99b4 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget { State createState() => _MessageAudioPlayerState(); } -class _MessageAudioPlayerState extends State with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { +class _MessageAudioPlayerState extends State with WidgetsBindingObserver { final AudioPlayer _audioPlayer = AudioPlayer(); Future? _audioFileFuture; double _sliderValue = 0; @@ -41,22 +41,32 @@ class _MessageAudioPlayerState extends State with WidgetsBin void didChangeDependencies() { super.didChangeDependencies(); final audioCache = Provider.of(context); - _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) - .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + _audioFileFuture = audioCache + .cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) => _audioPlayer.setFilePath(value.path)) + .whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); } @override void didUpdateWidget(covariant MessageAudioPlayer oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.message.id == widget.message.id) return; final audioCache = Provider.of(context); - _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) - .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + _audioFileFuture = audioCache + .cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) async { + final path = _audioPlayer.setFilePath(value.path); + await _audioPlayer.setLoopMode(LoopMode.off); + await _audioPlayer.pause(); + await _audioPlayer.seek(Duration.zero); + return path; + }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _audioPlayer.dispose(); + _audioPlayer.dispose().onError((error, stackTrace) {}); super.dispose(); } @@ -66,22 +76,19 @@ class _MessageAudioPlayerState extends State with WidgetsBin child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Icons.error_outline, color: Theme - .of(context) - .colorScheme - .error,), - const SizedBox(height: 4,), - Text(error, textAlign: TextAlign.center, + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox( + height: 4, + ), + Text( + error, + textAlign: TextAlign.center, softWrap: true, maxLines: 3, - style: Theme - .of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme - .of(context) - .colorScheme - .error), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), ), ], ), @@ -90,147 +97,140 @@ class _MessageAudioPlayerState extends State with WidgetsBin @override Widget build(BuildContext context) { - super.build(context); if (!Platform.isAndroid) { return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); } - return FutureBuilder( - future: _audioFileFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return SizedBox( - width: 300, - child: Row( - children: [ - const Icon(Icons.volume_off), - const SizedBox(width: 8,), - Expanded( - child: Text( - "Failed to load voice message: ${snapshot.error}", - maxLines: 4, - overflow: TextOverflow.ellipsis, - softWrap: true, - ), - ), - ], - ), - ); - } - return IntrinsicWidth( - child: StreamBuilder( - stream: _audioPlayer.playerStateStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final playerState = snapshot.data as PlayerState; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - snapshot.hasData ? IconButton( - onPressed: () { - switch (playerState.processingState) { - case ProcessingState.idle: - case ProcessingState.loading: - case ProcessingState.buffering: - break; - case ProcessingState.ready: - if (playerState.playing) { - _audioPlayer.pause(); - } else { - _audioPlayer.play(); - } - break; - case ProcessingState.completed: - _audioPlayer.seek(Duration.zero); - _audioPlayer.play(); - break; - } - }, - color: widget.foregroundColor, - icon: SizedBox.square( - dimension: 24, - child: playerState.processingState == ProcessingState.loading - ? const Center(child: CircularProgressIndicator(),) - : Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < - 10 ? Icons.replay - : (playerState.playing ? Icons.pause : Icons.play_arrow)), - ), - ) : const SizedBox.square(dimension: 24, child: CircularProgressIndicator(),), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - _sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds / - (_audioPlayer.duration!.inMilliseconds)).clamp(0, 1); - return StatefulBuilder( // Not sure if this makes sense here... - builder: (context, setState) { - return SliderTheme( - data: SliderThemeData( - inactiveTrackColor: widget.foregroundColor?.withAlpha(100), - ), - child: Slider( - thumbColor: widget.foregroundColor, - value: _sliderValue, - min: 0.0, - max: 1.0, - onChanged: (value) async { - _audioPlayer.pause(); - setState(() { - _sliderValue = value; - }); - _audioPlayer.seek(Duration( - milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), - )); - }, - ), - ); + + return IntrinsicWidth( + child: StreamBuilder( + stream: _audioPlayer.playerStateStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + return _createErrorWidget("Failed to load audio-message."); + } + final playerState = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FutureBuilder( + future: _audioFileFuture, + builder: (context, fileSnapshot) { + if (fileSnapshot.hasError) { + return const IconButton( + icon: Icon(Icons.warning), + onPressed: null, + ); + } + return IconButton( + onPressed: fileSnapshot.hasData && + snapshot.hasData && + playerState != null && + playerState.processingState != ProcessingState.loading + ? () { + switch (playerState.processingState) { + case ProcessingState.idle: + case ProcessingState.loading: + case ProcessingState.buffering: + break; + case ProcessingState.ready: + if (playerState.playing) { + _audioPlayer.pause(); + } else { + _audioPlayer.play(); } - ); + break; + case ProcessingState.completed: + _audioPlayer.seek(Duration.zero); + _audioPlayer.play(); + break; + } } - ) - ], - ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 4,), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? - "??"}", - style: Theme - .of(context) - .textTheme - .bodySmall - ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), + : null, + color: widget.foregroundColor, + icon: Icon( + ((_audioPlayer.duration ?? const Duration(days: 9999)) - _audioPlayer.position) + .inMilliseconds < + 10 + ? Icons.replay + : ((playerState?.playing ?? false) ? Icons.pause : Icons.play_arrow), + ), + ); + }, + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + _sliderValue = _audioPlayer.duration == null + ? 0 + : (_audioPlayer.position.inMilliseconds / (_audioPlayer.duration!.inMilliseconds)) + .clamp(0, 1); + return StatefulBuilder( + // Not sure if this makes sense here... + builder: (context, setState) { + return SliderTheme( + data: SliderThemeData( + inactiveTrackColor: widget.foregroundColor?.withAlpha(100), + ), + child: Slider( + thumbColor: widget.foregroundColor, + value: _sliderValue, + min: 0.0, + max: 1.0, + onChanged: (value) async { + _audioPlayer.pause(); + setState(() { + _sliderValue = value; + }); + _audioPlayer.seek( + Duration( + milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), + ), ); - } - ), - const Spacer(), - MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,), - ], - ) - ], - ); - } else if (snapshot.hasError) { - FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); - return _createErrorWidget("Failed to load audio-message."); - } else { - return const Center(child: CircularProgressIndicator(),); - } - } - ), - ); - } + }, + ), + ); + }, + ); + }, + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox( + width: 4, + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + return Text( + "${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? "??"}", + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), + ); + }, + ), + const Spacer(), + MessageStateIndicator( + message: widget.message, + foregroundColor: widget.foregroundColor, + ), + ], + ), + ], + ); + }, + ), ); } - - @override - // TODO: implement wantKeepAlive - bool get wantKeepAlive => true; -} \ No newline at end of file +} diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 5e5857e..de04404 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -42,8 +42,9 @@ class _MessagesListState extends State with SingleTickerProviderSt _showSessionListScrollChevron = true; }); } - if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0 - && _showSessionListScrollChevron) { + if (_sessionListScrollController.position.atEdge && + _sessionListScrollController.position.pixels > 0 && + _showSessionListScrollChevron) { setState(() { _showSessionListScrollChevron = false; }); @@ -54,185 +55,191 @@ class _MessagesListState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { final sessions = widget.friend.userStatus.activeSessions; - final appBarColor = Theme - .of(context) - .colorScheme - .surfaceVariant; - return Consumer( - builder: (context, mClient, _) { - final cache = mClient.getUserMessageCache(widget.friend.id); - return Scaffold( - appBar: AppBar( - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, + final appBarColor = Theme.of(context).colorScheme.surfaceVariant; + return Consumer(builder: (context, mClient, _) { + final cache = mClient.getUserMessageCache(widget.friend.id); + return Scaffold( + appBar: AppBar( + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus), + const SizedBox( + width: 8, + ), + Text(widget.friend.username), + if (widget.friend.isHeadless) + Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon( + Icons.dns, + size: 18, + color: Theme.of(context).colorScheme.onSecondaryContainer.withAlpha(150), + ), + ), + ], + ), + bottom: sessions.isNotEmpty && _sessionListOpen + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 1, + color: Colors.black, + ), + ), + actions: [ + if (sessions.isNotEmpty) + AnimatedRotation( + turns: _sessionListOpen ? -1 / 4 : 1 / 4, + duration: const Duration(milliseconds: 200), + child: IconButton( + onPressed: () { + setState(() { + _sessionListOpen = !_sessionListOpen; + }); + }, + icon: const Icon(Icons.chevron_right), + ), + ), + const SizedBox( + width: 4, + ) + ], + scrolledUnderElevation: 0.0, + backgroundColor: appBarColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + body: Column( + children: [ + if (sessions.isNotEmpty) + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => + SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), + child: sessions.isEmpty || !_sessionListOpen + ? null + : Container( + constraints: const BoxConstraints(maxHeight: 64), + decoration: BoxDecoration( + color: appBarColor, + border: const Border( + bottom: BorderSide(width: 1, color: Colors.black), + )), + child: Stack( + children: [ + ListView.builder( + controller: _sessionListScrollController, + scrollDirection: Axis.horizontal, + itemCount: sessions.length, + itemBuilder: (context, index) => SessionTile(session: sessions[index]), + ), + AnimatedOpacity( + opacity: _shevronOpacity, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 200), + child: Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + appBarColor.withOpacity(0), + appBarColor, + appBarColor, + ], + ), + ), + height: double.infinity, + child: const Icon(Icons.chevron_right), + ), + ), + ) + ], + ), + ), + ), + Expanded( + child: Stack( children: [ - FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus), - const SizedBox(width: 8,), - Text(widget.friend.username), - if (widget.friend.isHeadless) Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon(Icons.dns, size: 18, color: Theme - .of(context) - .colorScheme - .onSecondaryContainer - .withAlpha(150), - ), + Builder( + builder: (context) { + if (cache == null) { + return const Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [LinearProgressIndicator()], + ); + } + if (cache.error != null) { + return DefaultErrorWidget( + message: cache.error.toString(), + onRetry: () { + setState(() { + mClient.deleteUserMessageCache(widget.friend.id); + }); + mClient.loadUserMessageCache(widget.friend.id); + }, + ); + } + if (cache.messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.message_outlined), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + "There are no messages here\nWhy not say hello?", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ) + ], + ), + ); + } + return Provider( + create: (BuildContext context) => AudioCacheClient(), + child: ListView.builder( + reverse: true, + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), + itemCount: cache.messages.length, + itemBuilder: (context, index) { + final entry = cache.messages[index]; + if (index == cache.messages.length - 1) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: MessageBubble( + message: entry, + ), + ); + } + return MessageBubble( + message: entry, + ); + }, + ), + ); + }, ), ], ), - bottom: sessions.isNotEmpty && _sessionListOpen ? null : PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container( - height: 1, - color: Colors.black, - ), - ), - actions: [ - if (sessions.isNotEmpty) AnimatedRotation( - turns: _sessionListOpen ? -1/4 : 1/4, - duration: const Duration(milliseconds: 200), - child: IconButton( - onPressed: () { - setState(() { - _sessionListOpen = !_sessionListOpen; - }); - }, - icon: const Icon(Icons.chevron_right), - ), - ), - const SizedBox(width: 4,) - ], - scrolledUnderElevation: 0.0, - backgroundColor: appBarColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, ), - body: Column( - children: [ - if (sessions.isNotEmpty) AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), - child: sessions.isEmpty || !_sessionListOpen ? null : Container( - constraints: const BoxConstraints(maxHeight: 64), - decoration: BoxDecoration( - color: appBarColor, - border: const Border(bottom: BorderSide(width: 1, color: Colors.black),) - ), - child: Stack( - children: [ - ListView.builder( - controller: _sessionListScrollController, - scrollDirection: Axis.horizontal, - itemCount: sessions.length, - itemBuilder: (context, index) => SessionTile(session: sessions[index]), - ), - AnimatedOpacity( - opacity: _shevronOpacity, - curve: Curves.easeOut, - duration: const Duration(milliseconds: 200), - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - appBarColor.withOpacity(0), - appBarColor, - appBarColor, - ], - ), - ), - height: double.infinity, - child: const Icon(Icons.chevron_right), - ), - ), - ) - ], - ), - ), - ), - Expanded( - child: Stack( - children: [ - Builder( - builder: (context) { - if (cache == null) { - return const Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - LinearProgressIndicator() - ], - ); - } - if (cache.error != null) { - return DefaultErrorWidget( - message: cache.error.toString(), - onRetry: () { - setState(() { - mClient.deleteUserMessageCache(widget.friend.id); - }); - mClient.loadUserMessageCache(widget.friend.id); - }, - ); - } - if (cache.messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.message_outlined), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - "There are no messages here\nWhy not say hello?", - textAlign: TextAlign.center, - style: Theme - .of(context) - .textTheme - .titleMedium, - ), - ) - ], - ), - ); - } - return Provider( - create: (BuildContext context) => AudioCacheClient(), - child: ListView.builder( - reverse: true, - physics: const BouncingScrollPhysics(), - itemCount: cache.messages.length, - itemBuilder: (context, index) { - final entry = cache.messages[index]; - if (index == cache.messages.length - 1) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: MessageBubble(message: entry,), - ); - } - return MessageBubble(message: entry,); - }, - ), - ); - }, - ), - ], - ), - ), - MessageInputBar( - recipient: widget.friend, - disabled: cache == null || cache.error != null, - onMessageSent: () { - setState(() {}); - }, - ), - ], + MessageInputBar( + recipient: widget.friend, + disabled: cache == null || cache.error != null, + onMessageSent: () { + setState(() {}); + }, ), - ); - } - ); + ], + ), + ); + }); } }