fix: Improve table rendering using flutter_widget_from_html and Improve Image Preview funcionality
>> >> - Replace flutter_html with flutter_widget_from_html for better table support >> - Add custom table styling >> - Add horizontal scrolling for wide tables >> - Implement better cell padding and borders >> - Add fullscreen preview capability >> - Implement zoom controls with double tap and pinch gestures
This commit is contained in:
parent
64b0b39deb
commit
fa8a656dbc
|
|
@ -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/';
|
||||||
|
|
|
||||||
|
|
@ -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:english_learning/features/learning/modules/material/widgets/video_player_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:english_learning/core/utils/styles/theme.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';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MaterialScreen extends StatefulWidget {
|
class MaterialScreen extends StatefulWidget {
|
||||||
|
|
@ -152,33 +152,53 @@ class _MaterialScreenState extends State<MaterialScreen>
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Html(
|
HtmlWidget(
|
||||||
data: level.content,
|
level.content,
|
||||||
style: {
|
customStylesBuilder: (element) {
|
||||||
'body': Style(
|
if (element.localName == 'table') {
|
||||||
fontSize: FontSize(14),
|
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,
|
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),
|
const SizedBox(height: 32),
|
||||||
if (!widget.isReview)
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
import 'package:english_learning/features/learning/modules/material/widgets/loading/media_loading_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AudioPlayerWidget extends StatefulWidget {
|
class AudioPlayerWidget extends StatefulWidget {
|
||||||
|
|
@ -15,65 +16,276 @@ class AudioPlayerWidget extends StatefulWidget {
|
||||||
State<AudioPlayerWidget> createState() => AudioPlayerWidgetState();
|
State<AudioPlayerWidget> createState() => AudioPlayerWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
class AudioPlayerWidgetState extends State<AudioPlayerWidget>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
late AudioPlayer _audioPlayer;
|
late AudioPlayer _audioPlayer;
|
||||||
|
late AnimationController _loadingController;
|
||||||
|
late AnimationController _fadeController;
|
||||||
|
|
||||||
PlayerState _playerState = PlayerState.stopped;
|
PlayerState _playerState = PlayerState.stopped;
|
||||||
Duration _duration = Duration.zero;
|
Duration _duration = Duration.zero;
|
||||||
Duration _position = Duration.zero;
|
Duration _position = Duration.zero;
|
||||||
bool _isAudioLoaded = false;
|
bool _isAudioLoaded = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
double _volume = 1.0;
|
double _volume = 1.0;
|
||||||
|
bool _isDisposed = false;
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_audioPlayer = AudioPlayer();
|
_audioPlayer = AudioPlayer();
|
||||||
|
_loadingController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
_fadeController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
|
||||||
_setupAudioPlayer();
|
_setupAudioPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupAudioPlayer() {
|
void _setupAudioPlayer() {
|
||||||
if (widget.audioFileName.isNotEmpty) {
|
if (widget.audioFileName.isNotEmpty) {
|
||||||
String fullAudioUrl = widget.audioFileName.startsWith('http')
|
String fullAudioUrl = _getFullAudioUrl();
|
||||||
? widget.audioFileName
|
|
||||||
: '${widget.baseUrl ?? ''}${widget.audioFileName}';
|
|
||||||
|
|
||||||
_audioPlayer.setSource(UrlSource(fullAudioUrl)).then((_) {
|
_audioPlayer.setSource(UrlSource(fullAudioUrl)).then((_) {
|
||||||
|
if (!_isDisposed) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isAudioLoaded = true;
|
_isAudioLoaded = true;
|
||||||
|
_fadeController.forward();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
|
if (!_isDisposed) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = "Failed to load audio";
|
_errorMessage = "Failed to load audio: $error";
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_setupAudioListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupAudioListeners() {
|
||||||
_audioPlayer.onPlayerStateChanged.listen((state) {
|
_audioPlayer.onPlayerStateChanged.listen((state) {
|
||||||
setState(() {
|
if (!_isDisposed) {
|
||||||
_playerState = state;
|
setState(() => _playerState = state);
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_audioPlayer.onDurationChanged.listen((newDuration) {
|
_audioPlayer.onDurationChanged.listen((newDuration) {
|
||||||
setState(() {
|
if (!_isDisposed) {
|
||||||
_duration = newDuration;
|
setState(() => _duration = newDuration);
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_audioPlayer.onPositionChanged.listen((newPosition) {
|
_audioPlayer.onPositionChanged.listen((newPosition) {
|
||||||
setState(() {
|
if (!_isDisposed) {
|
||||||
_position = newPosition;
|
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
|
@override
|
||||||
void dispose() {
|
Widget build(BuildContext context) {
|
||||||
_audioPlayer.stop();
|
if (_errorMessage != null) {
|
||||||
_audioPlayer.dispose();
|
return _buildErrorWidget();
|
||||||
super.dispose();
|
}
|
||||||
|
|
||||||
|
if (!_isAudioLoaded) {
|
||||||
|
return const AudioShimmerLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildAudioControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
String _formatDuration(Duration duration) {
|
||||||
|
|
@ -89,123 +301,11 @@ class AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void dispose() {
|
||||||
if (_errorMessage != null) {
|
_isDisposed = true;
|
||||||
return Text(
|
_audioPlayer.dispose();
|
||||||
_errorMessage!,
|
_loadingController.dispose();
|
||||||
style: const TextStyle(
|
_fadeController.dispose();
|
||||||
color: Colors.red,
|
super.dispose();
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,204 @@
|
||||||
|
// ignore_for_file: unused_field
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ImageWidget extends StatelessWidget {
|
class ImageWidget extends StatefulWidget {
|
||||||
final String imageFileName;
|
final String imageFileName;
|
||||||
final String? baseUrl;
|
final String? baseUrl;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
|
||||||
const ImageWidget({
|
const ImageWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageFileName,
|
required this.imageFileName,
|
||||||
this.baseUrl,
|
this.baseUrl,
|
||||||
|
this.height = 200,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<ImageWidget> createState() => _ImageWidgetState();
|
||||||
String fullImageUrl = imageFileName.startsWith('http')
|
}
|
||||||
? imageFileName
|
|
||||||
: '${baseUrl ?? ''}$imageFileName';
|
|
||||||
|
|
||||||
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 200,
|
height: widget.height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1),
|
color: Colors.black.withOpacity(0.1),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: Offset(0, 5),
|
offset: const Offset(0, 5),
|
||||||
|
spreadRadius: 0,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: CachedNetworkImage(
|
child: Stack(
|
||||||
imageUrl: fullImageUrl,
|
fit: StackFit.expand,
|
||||||
fit: BoxFit.cover,
|
children: [
|
||||||
placeholder: (context, url) =>
|
Container(color: Colors.grey[200]),
|
||||||
const Center(child: CircularProgressIndicator()),
|
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(
|
errorWidget: (context, url, error) => Container(
|
||||||
color: Colors.grey[300],
|
color: Colors.grey[300],
|
||||||
child: const Icon(
|
child: const Center(
|
||||||
Icons.error_outline,
|
child: Icon(Icons.error_outline, color: Colors.red),
|
||||||
color: Colors.red,
|
),
|
||||||
size: 50,
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
pubspec.lock
18
pubspec.lock
|
|
@ -334,14 +334,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
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:
|
flutter_inappwebview:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -577,7 +569,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.6"
|
version: "5.0.6"
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: html
|
name: html
|
||||||
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
|
||||||
|
|
@ -760,14 +752,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ dependencies:
|
||||||
flick_video_player: ^0.9.0
|
flick_video_player: ^0.9.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_html: ^3.0.0-beta.2
|
|
||||||
flutter_inappwebview: ^6.0.0
|
flutter_inappwebview: ^6.0.0
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
flutter_svg: ^2.0.10+1
|
flutter_svg: ^2.0.10+1
|
||||||
flutter_widget_from_html: ^0.15.2
|
flutter_widget_from_html: ^0.15.2
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
google_nav_bar: ^5.0.6
|
google_nav_bar: ^5.0.6
|
||||||
|
html: ^0.15.4
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user