Merge branch 'material-loading' into 'master'

fix: Improve table rendering using flutter_widget_from_html and Improve Image Preview funcionality

See merge request profile-image/kedaireka/polinema-adapative-learning/mobile-adaptive-learning!14
This commit is contained in:
Naresh Pratista 2024-11-14 06:36:45 +00:00
commit 10b2c8010d
7 changed files with 645 additions and 261 deletions

View File

@ -1 +1 @@
const String baseUrl = 'https://ea80-114-6-25-184.ngrok-free.app/';
const String baseUrl = 'https://8b78-114-6-25-184.ngrok-free.app/';

View File

@ -10,7 +10,7 @@ import 'package:english_learning/features/learning/modules/material/widgets/imag
import 'package:english_learning/features/learning/modules/material/widgets/video_player_widget.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
import 'package:provider/provider.dart';
class MaterialScreen extends StatefulWidget {
@ -152,33 +152,53 @@ class _MaterialScreenState extends State<MaterialScreen>
child: Column(
children: [
const SizedBox(height: 22),
Html(
data: level.content,
style: {
'body': Style(
fontSize: FontSize(14),
HtmlWidget(
level.content,
customStylesBuilder: (element) {
if (element.localName == 'table') {
return {'width': '100%', 'border-collapse': 'collapse'};
}
if (element.localName == 'th' ||
element.localName == 'td') {
return {
'padding': '8px',
'border': '1px solid #ddd',
'text-align': 'center',
};
}
if (element.localName == 'th') {
return {
'background-color': '#f5f5f5',
'font-weight': 'bold',
};
}
return null;
},
customWidgetBuilder: (element) {
if (element.localName == 'img') {
return ImageWidget(
imageFileName: element.attributes['src'] ?? '',
);
}
if (element.localName == 'audio') {
return AudioPlayerWidget(
key: ValueKey('audio_${element.attributes['src']}'),
audioFileName: element.attributes['src'] ?? '',
);
}
if (element.localName == 'iframe') {
return VideoPlayerWidget(
key: ValueKey('video_${element.attributes['src']}'),
videoUrl: element.attributes['src'] ?? '',
);
}
return null;
},
renderMode: RenderMode.column,
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
},
extensions: [
ImageExtension(
builder: (context) => ImageWidget(
imageFileName: context.attributes['src'] ?? '',
),
),
AudioExtension(
builder: (context) => AudioPlayerWidget(
key: ValueKey('audio_${context.attributes['src']}'),
audioFileName: context.attributes['src'] ?? '',
),
),
VideoExtension(
builder: (context) => VideoPlayerWidget(
key: ValueKey('video_${context.attributes['src']}'),
videoUrl: context.attributes['src'] ?? '',
),
),
],
),
const SizedBox(height: 32),
if (!widget.isReview)
@ -211,49 +231,3 @@ class _MaterialScreenState extends State<MaterialScreen>
});
}
}
class ImageExtension extends HtmlExtension {
final Widget Function(ExtensionContext) builder;
ImageExtension({required this.builder});
@override
Set<String> get supportedTags => {'img'};
@override
InlineSpan build(ExtensionContext context) {
context.attributes.remove('width');
context.attributes.remove('height');
return WidgetSpan(child: builder(context));
}
}
class AudioExtension extends HtmlExtension {
final Widget Function(ExtensionContext) builder;
AudioExtension({required this.builder});
@override
Set<String> get supportedTags => {'audio'};
@override
InlineSpan build(ExtensionContext context) =>
WidgetSpan(child: builder(context));
}
class VideoExtension extends HtmlExtension {
final Widget Function(ExtensionContext) builder;
VideoExtension({required this.builder});
@override
Set<String> get supportedTags => {'iframe'};
@override
InlineSpan build(ExtensionContext context) {
context.attributes.remove('width');
context.attributes.remove('height');
return WidgetSpan(child: builder(context));
}
}

View File

@ -1,4 +1,5 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:english_learning/features/learning/modules/material/widgets/loading/media_loading_widget.dart';
import 'package:flutter/material.dart';
class AudioPlayerWidget extends StatefulWidget {
@ -15,65 +16,276 @@ class AudioPlayerWidget extends StatefulWidget {
State<AudioPlayerWidget> createState() => AudioPlayerWidgetState();
}
class AudioPlayerWidgetState extends State<AudioPlayerWidget> {
class AudioPlayerWidgetState extends State<AudioPlayerWidget>
with TickerProviderStateMixin {
late AudioPlayer _audioPlayer;
late AnimationController _loadingController;
late AnimationController _fadeController;
PlayerState _playerState = PlayerState.stopped;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
bool _isAudioLoaded = false;
String? _errorMessage;
double _volume = 1.0;
bool get wantKeepAlive => true;
bool _isDisposed = false;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
_loadingController = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_setupAudioPlayer();
}
void _setupAudioPlayer() {
if (widget.audioFileName.isNotEmpty) {
String fullAudioUrl = widget.audioFileName.startsWith('http')
? widget.audioFileName
: '${widget.baseUrl ?? ''}${widget.audioFileName}';
String fullAudioUrl = _getFullAudioUrl();
_audioPlayer.setSource(UrlSource(fullAudioUrl)).then((_) {
if (!_isDisposed) {
setState(() {
_isAudioLoaded = true;
_fadeController.forward();
});
}
}).catchError((error) {
if (!_isDisposed) {
setState(() {
_errorMessage = "Failed to load audio";
_errorMessage = "Failed to load audio: $error";
});
}
});
_setupAudioListeners();
}
}
void _setupAudioListeners() {
_audioPlayer.onPlayerStateChanged.listen((state) {
setState(() {
_playerState = state;
});
if (!_isDisposed) {
setState(() => _playerState = state);
}
});
_audioPlayer.onDurationChanged.listen((newDuration) {
setState(() {
_duration = newDuration;
});
if (!_isDisposed) {
setState(() => _duration = newDuration);
}
});
_audioPlayer.onPositionChanged.listen((newPosition) {
setState(() {
_position = newPosition;
});
if (!_isDisposed) {
setState(() => _position = newPosition);
}
});
}
String _getFullAudioUrl() {
return widget.audioFileName.startsWith('http')
? widget.audioFileName
: '${widget.baseUrl ?? ''}${widget.audioFileName}';
}
Widget _buildErrorWidget() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
color: Colors.red[400],
size: 32,
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Error loading audio',
style: TextStyle(
color: Colors.red[400],
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: () {
setState(() {
_errorMessage = null;
_isAudioLoaded = false;
_setupAudioPlayer();
});
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
),
),
],
),
);
}
Widget _buildAudioControls() {
return FadeTransition(
opacity: _fadeController,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_buildPlayButton(),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTimelineIndicator(),
const SizedBox(height: 4),
_buildProgressSlider(),
],
),
),
const SizedBox(width: 12),
_buildVolumeButton(),
],
),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_errorMessage!,
style: TextStyle(
color: Colors.red[400],
fontSize: 12,
),
),
),
],
),
),
);
}
Widget _buildPlayButton() {
return IconButton(
icon: Icon(
_playerState == PlayerState.playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.blue,
size: 32,
),
onPressed: _isAudioLoaded
? () {
if (_playerState == PlayerState.playing) {
_audioPlayer.pause();
} else {
_audioPlayer.resume();
}
}
: null,
);
}
Widget _buildTimelineIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_position),
style: const TextStyle(fontSize: 12),
),
Text(
_formatDuration(_duration),
style: const TextStyle(fontSize: 12),
),
],
);
}
Widget _buildProgressSlider() {
return SliderTheme(
data: const SliderThemeData(
trackHeight: 4,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: _position.inSeconds.toDouble(),
min: 0.0,
max: _duration.inSeconds.toDouble(),
onChanged: _isAudioLoaded
? (value) {
final position = Duration(seconds: value.toInt());
_audioPlayer.seek(position);
}
: null,
activeColor: Colors.blue,
inactiveColor: Colors.grey[300],
),
);
}
Widget _buildVolumeButton() {
return IconButton(
icon: Icon(
_volume == 0.0 ? Icons.volume_off : Icons.volume_up,
color: _volume == 0.0 ? Colors.grey : Colors.blue,
size: 24,
),
onPressed: _isAudioLoaded
? () {
if (_volume == 0.0) {
_audioPlayer.setVolume(1.0);
setState(() => _volume = 1.0);
} else {
_audioPlayer.setVolume(0.0);
setState(() => _volume = 0.0);
}
}
: null,
);
}
@override
void dispose() {
_audioPlayer.stop();
_audioPlayer.dispose();
super.dispose();
Widget build(BuildContext context) {
if (_errorMessage != null) {
return _buildErrorWidget();
}
if (!_isAudioLoaded) {
return const AudioShimmerLoader();
}
return _buildAudioControls();
}
String _formatDuration(Duration duration) {
@ -89,123 +301,11 @@ class AudioPlayerWidgetState extends State<AudioPlayerWidget> {
}
@override
Widget build(BuildContext context) {
if (_errorMessage != null) {
return Text(
_errorMessage!,
style: const TextStyle(
color: Colors.red,
),
);
}
if (!_isAudioLoaded) {
return const CircularProgressIndicator();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Play/Pause Button
IconButton(
icon: Icon(
_playerState == PlayerState.playing
? Icons.pause
: Icons.play_arrow,
color: Colors.blue,
size: 32,
),
onPressed: () {
if (_playerState == PlayerState.playing) {
_audioPlayer.pause();
} else {
_audioPlayer.resume();
}
},
),
const SizedBox(width: 12),
// Timeline and Slider
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timeline
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_position),
style: const TextStyle(
fontSize: 12,
),
),
Text(
_formatDuration(_duration),
style: const TextStyle(
fontSize: 12,
),
),
],
),
const SizedBox(height: 4),
// Slider
SliderTheme(
data: const SliderThemeData(
trackHeight: 4,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: _position.inSeconds.toDouble(),
min: 0.0,
max: _duration.inSeconds.toDouble(),
onChanged: (value) {
final position = Duration(seconds: value.toInt());
_audioPlayer.seek(position);
},
activeColor: Colors.blue,
inactiveColor: Colors.grey,
),
),
],
),
),
const SizedBox(width: 12),
// Mute Button
IconButton(
icon: Icon(
Icons.volume_up,
color: _volume == 0.0 ? Colors.grey : Colors.blue,
size: 32,
),
onPressed: () {
if (_volume == 0.0) {
_audioPlayer.setVolume(1.0);
setState(() {
_volume = 1.0;
});
} else {
_audioPlayer.setVolume(0.0);
setState(() {
_volume = 0.0;
});
}
},
),
],
),
);
void dispose() {
_isDisposed = true;
_audioPlayer.dispose();
_loadingController.dispose();
_fadeController.dispose();
super.dispose();
}
}

View File

@ -1,48 +1,204 @@
// ignore_for_file: unused_field
import 'package:cached_network_image/cached_network_image.dart';
import 'package:english_learning/features/learning/modules/material/widgets/loading/media_loading_widget.dart';
import 'package:flutter/material.dart';
class ImageWidget extends StatelessWidget {
class ImageWidget extends StatefulWidget {
final String imageFileName;
final String? baseUrl;
final double? height;
final BoxFit fit;
const ImageWidget({
super.key,
required this.imageFileName,
this.baseUrl,
this.height = 200,
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
String fullImageUrl = imageFileName.startsWith('http')
? imageFileName
: '${baseUrl ?? ''}$imageFileName';
State<ImageWidget> createState() => _ImageWidgetState();
}
class _ImageWidgetState extends State<ImageWidget>
with TickerProviderStateMixin {
late AnimationController _loadingController;
late AnimationController _fadeController;
bool _isImageLoaded = false;
String? _errorMessage;
final TransformationController _transformationController =
TransformationController();
@override
void initState() {
super.initState();
_loadingController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
_loadingController.dispose();
_fadeController.dispose();
_transformationController.dispose();
super.dispose();
}
String _getFullImageUrl() {
if (widget.imageFileName.isEmpty) {
throw Exception('Image file name cannot be empty');
}
return widget.imageFileName.startsWith('http')
? widget.imageFileName
: '${widget.baseUrl ?? ''}${widget.imageFileName}';
}
void _showFullScreenImage(BuildContext context, ImageProvider imageProvider) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => FullScreenImageView(
imageProvider: imageProvider,
imageUrl: _getFullImageUrl(),
),
),
);
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 200,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: Offset(0, 5),
offset: const Offset(0, 5),
spreadRadius: 0,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: fullImageUrl,
fit: BoxFit.cover,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
child: Stack(
fit: StackFit.expand,
children: [
Container(color: Colors.grey[200]),
CachedNetworkImage(
imageUrl: _getFullImageUrl(),
fit: widget.fit,
fadeInDuration: const Duration(milliseconds: 300),
fadeOutDuration: const Duration(milliseconds: 300),
imageBuilder: (context, imageProvider) {
_isImageLoaded = true;
_fadeController.forward();
return GestureDetector(
onTap: () => _showFullScreenImage(context, imageProvider),
child: Image(image: imageProvider, fit: widget.fit),
);
},
placeholder: (context, url) => MediaLoadingWidget(
width: double.infinity,
height: widget.height ?? 200,
borderRadius: BorderRadius.circular(12),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: const Icon(
Icons.error_outline,
color: Colors.red,
size: 50,
child: const Center(
child: Icon(Icons.error_outline, color: Colors.red),
),
),
),
],
),
),
);
}
}
class FullScreenImageView extends StatefulWidget {
final ImageProvider imageProvider;
final String imageUrl;
const FullScreenImageView({
super.key,
required this.imageProvider,
required this.imageUrl,
});
@override
State<FullScreenImageView> createState() => _FullScreenImageViewState();
}
class _FullScreenImageViewState extends State<FullScreenImageView> {
late TransformationController _controller;
late TapDownDetails _doubleTapDetails;
@override
void initState() {
super.initState();
_controller = TransformationController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleDoubleTapDown(TapDownDetails details) {
_doubleTapDetails = details;
}
void _handleDoubleTap() {
if (_controller.value != Matrix4.identity()) {
_controller.value = Matrix4.identity();
} else {
final position = _doubleTapDetails.localPosition;
_controller.value = Matrix4.identity()
..translate(-position.dx * 1, -position.dy * 1)
..scale(2.0);
}
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
),
body: GestureDetector(
onDoubleTapDown: _handleDoubleTapDown,
onDoubleTap: _handleDoubleTap,
child: Center(
child: InteractiveViewer(
transformationController: _controller,
minScale: 1.0,
maxScale: 4.0,
child: Hero(
tag: widget.imageUrl,
child: Image(
image: widget.imageProvider,
fit: BoxFit.contain,
),
),
),
),

View File

@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class MediaLoadingWidget extends StatelessWidget {
final double width;
final double height;
final BorderRadius? borderRadius;
final EdgeInsets? padding;
const MediaLoadingWidget({
super.key,
required this.width,
required this.height,
this.borderRadius,
this.padding,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? EdgeInsets.zero,
child: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
period: const Duration(milliseconds: 1500),
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: borderRadius ?? BorderRadius.circular(12),
),
),
),
);
}
}
class MediaShimmerLoader extends StatelessWidget {
final double aspectRatio;
final bool showPlayIcon;
const MediaShimmerLoader({
super.key,
this.aspectRatio = 16 / 9,
this.showPlayIcon = false,
});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: aspectRatio,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[200],
),
child: Stack(
alignment: Alignment.center,
children: [
Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
period: const Duration(milliseconds: 1500),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
if (showPlayIcon)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(0.5),
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 32,
),
),
],
),
),
);
}
}
class AudioShimmerLoader extends StatelessWidget {
const AudioShimmerLoader({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
period: const Duration(milliseconds: 1500),
child: Row(
children: [
// Play button shimmer
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
// Progress bar shimmer
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Time indicators
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 30,
height: 10,
color: Colors.white,
),
Container(
width: 30,
height: 10,
color: Colors.white,
),
],
),
const SizedBox(height: 8),
// Progress bar
Container(
height: 4,
color: Colors.white,
),
],
),
),
const SizedBox(width: 12),
// Volume button shimmer
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
],
),
),
);
}
}

View File

@ -334,14 +334,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_html:
dependency: "direct main"
description:
name: flutter_html
sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee"
url: "https://pub.dev"
source: hosted
version: "3.0.0-beta.2"
flutter_inappwebview:
dependency: "direct main"
description:
@ -577,7 +569,7 @@ packages:
source: hosted
version: "5.0.6"
html:
dependency: transitive
dependency: "direct main"
description:
name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
@ -760,14 +752,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
list_counter:
dependency: transitive
description:
name: list_counter
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
url: "https://pub.dev"
source: hosted
version: "1.0.2"
logging:
dependency: transitive
description:

View File

@ -37,13 +37,13 @@ dependencies:
flick_video_player: ^0.9.0
flutter:
sdk: flutter
flutter_html: ^3.0.0-beta.2
flutter_inappwebview: ^6.0.0
flutter_secure_storage: ^9.2.2
flutter_svg: ^2.0.10+1
flutter_widget_from_html: ^0.15.2
google_fonts: ^6.2.1
google_nav_bar: ^5.0.6
html: ^0.15.4
image_picker: ^1.1.2
intl: ^0.19.0
jwt_decoder: ^2.0.1