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:
commit
10b2c8010d
|
|
@ -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: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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user