Replace 13 occurrences of BorderRadius.all(Radius.circular(8)) with a shared kCardBorderRadius constant in constants.dart. All uses represent the same design token: card/thumbnail corner radius across stream cards, category cards, and their skeleton loader placeholders. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
453 lines
14 KiB
Dart
453 lines
14 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:frosty/constants.dart';
|
|
|
|
/// A skeleton loading widget that displays a pulsing placeholder
|
|
class SkeletonLoader extends StatefulWidget {
|
|
final double? width;
|
|
final double? height;
|
|
final BorderRadius? borderRadius;
|
|
|
|
const SkeletonLoader({super.key, this.width, this.height, this.borderRadius});
|
|
|
|
@override
|
|
State<SkeletonLoader> createState() => _SkeletonLoaderState();
|
|
}
|
|
|
|
class _SkeletonLoaderState extends State<SkeletonLoader>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 1200),
|
|
vsync: this,
|
|
);
|
|
_animation = Tween<double>(begin: 0.3, end: 0.9).animate(
|
|
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
|
);
|
|
_animationController.repeat(reverse: true);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final brightness = Theme.of(context).brightness;
|
|
|
|
// Make skeleton more visible in dark mode
|
|
final baseColor = brightness == Brightness.dark
|
|
? colorScheme.surfaceContainerHighest
|
|
: colorScheme.surfaceContainer;
|
|
|
|
return AnimatedBuilder(
|
|
animation: _animation,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: widget.width,
|
|
height: widget.height,
|
|
decoration: BoxDecoration(
|
|
color: baseColor.withValues(alpha: _animation.value),
|
|
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader with random width for text placeholders
|
|
class SkeletonText extends StatelessWidget {
|
|
final double height;
|
|
final double? minWidth;
|
|
final double? maxWidth;
|
|
final BorderRadius? borderRadius;
|
|
|
|
const SkeletonText({
|
|
super.key,
|
|
required this.height,
|
|
this.minWidth,
|
|
this.maxWidth,
|
|
this.borderRadius,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final random = Random();
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
|
|
// Calculate random width based on provided constraints or reasonable defaults
|
|
final min = minWidth ?? screenWidth * 0.3;
|
|
final max = maxWidth ?? screenWidth * 0.8;
|
|
final randomWidth = min + random.nextDouble() * (max - min);
|
|
|
|
return SkeletonLoader(
|
|
width: randomWidth,
|
|
height: height,
|
|
borderRadius: borderRadius ?? const BorderRadius.all(Radius.circular(4)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for channel list items
|
|
class ChannelSkeletonLoader extends StatelessWidget {
|
|
final bool showSubtitle;
|
|
final int index;
|
|
|
|
const ChannelSkeletonLoader({
|
|
super.key,
|
|
this.showSubtitle = true,
|
|
this.index = 0,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Show subtitle for first 3-4 items (simulating live channels at top)
|
|
// then single line for the rest
|
|
final shouldShowSubtitle = showSubtitle && index < 4;
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
leading: const SkeletonLoader(
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
|
),
|
|
title: SkeletonText(height: 16, minWidth: 60, maxWidth: 140),
|
|
subtitle: shouldShowSubtitle
|
|
? SkeletonText(height: 14, minWidth: 30, maxWidth: 80)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for category list items
|
|
class CategorySkeletonLoader extends StatelessWidget {
|
|
const CategorySkeletonLoader({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: 8,
|
|
bottom: 8,
|
|
left: 16 + MediaQuery.of(context).padding.left,
|
|
right: 16 + MediaQuery.of(context).padding.right,
|
|
),
|
|
child: Row(
|
|
spacing: 16,
|
|
children: [
|
|
// Category box art skeleton (3:4 aspect ratio, 80px wide)
|
|
SizedBox(
|
|
width: 80,
|
|
child: AspectRatio(
|
|
aspectRatio: 3 / 4,
|
|
child: const SkeletonLoader(
|
|
borderRadius: kCardBorderRadius,
|
|
),
|
|
),
|
|
),
|
|
// Category name skeleton with random width (matches bodyLarge text)
|
|
Flexible(
|
|
child: SkeletonText(height: 18, minWidth: 80, maxWidth: 180),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for stream cards with thumbnail
|
|
class StreamCardSkeletonLoader extends StatelessWidget {
|
|
final bool showThumbnail;
|
|
final bool showCategory;
|
|
|
|
const StreamCardSkeletonLoader({
|
|
super.key,
|
|
this.showThumbnail = true,
|
|
this.showCategory = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: 8,
|
|
bottom: 8,
|
|
left: showThumbnail
|
|
? 16 + MediaQuery.of(context).padding.left
|
|
: 4 + MediaQuery.of(context).padding.left,
|
|
right: showThumbnail
|
|
? 16 + MediaQuery.of(context).padding.right
|
|
: 4 + MediaQuery.of(context).padding.right,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (showThumbnail) ...[
|
|
// Thumbnail skeleton (16:9 aspect ratio)
|
|
Flexible(
|
|
child: AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: const SkeletonLoader(
|
|
borderRadius: kCardBorderRadius,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Stream info skeleton
|
|
Flexible(
|
|
flex: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
spacing: 8,
|
|
children: [
|
|
// Streamer name row
|
|
Row(
|
|
spacing: 4,
|
|
children: [
|
|
const SkeletonLoader(
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
|
),
|
|
Expanded(
|
|
child: SkeletonText(
|
|
height: 16,
|
|
minWidth: 60,
|
|
maxWidth: 120,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Stream title skeleton with random width
|
|
SkeletonText(height: 14, minWidth: 100, maxWidth: 200),
|
|
if (showCategory) ...[
|
|
// Category skeleton with random width
|
|
SkeletonText(height: 14, minWidth: 60, maxWidth: 120),
|
|
],
|
|
// Viewer count skeleton with random width
|
|
SkeletonText(height: 14, minWidth: 50, maxWidth: 90),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for StreamInfoBar component
|
|
class StreamInfoBarSkeletonLoader extends StatelessWidget {
|
|
final bool showCategory;
|
|
final bool showUptime;
|
|
final bool showViewerCount;
|
|
final EdgeInsets padding;
|
|
|
|
const StreamInfoBarSkeletonLoader({
|
|
super.key,
|
|
this.showCategory = true,
|
|
this.showUptime = true,
|
|
this.showViewerCount = true,
|
|
this.padding = EdgeInsets.zero,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Show bottom row if any of the conditional elements should be displayed
|
|
final showBottomRow = showUptime || showViewerCount || showCategory;
|
|
|
|
return Padding(
|
|
padding: padding,
|
|
child: Row(
|
|
spacing: 12,
|
|
children: [
|
|
// ProfilePicture skeleton (radius 16)
|
|
const SkeletonLoader(
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
spacing: 2,
|
|
children: [
|
|
// Top row: Streamer name + stream title
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
spacing: 8,
|
|
children: [
|
|
// Streamer name skeleton
|
|
SkeletonText(height: 14, minWidth: 60, maxWidth: 120),
|
|
// Stream title skeleton (flexible)
|
|
Flexible(
|
|
child: SkeletonText(
|
|
height: 14,
|
|
minWidth: 80,
|
|
maxWidth: 200,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Bottom row: Live indicator, uptime, viewer count, game name
|
|
if (showBottomRow) ...[
|
|
Row(
|
|
children: [
|
|
if (showUptime || showViewerCount) ...[
|
|
// Live indicator skeleton (matches LiveIndicator default size)
|
|
const SkeletonLoader(
|
|
width: 8.0,
|
|
height: 8.0,
|
|
borderRadius: BorderRadius.all(Radius.circular(4)),
|
|
),
|
|
const SizedBox(width: 6),
|
|
],
|
|
if (showUptime) ...[
|
|
// Uptime skeleton
|
|
SkeletonText(height: 14, minWidth: 40, maxWidth: 70),
|
|
if (showViewerCount) const SizedBox(width: 8),
|
|
],
|
|
if (showViewerCount) ...[
|
|
// Viewer count icon skeleton
|
|
const SkeletonLoader(
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: BorderRadius.all(Radius.circular(2)),
|
|
),
|
|
const SizedBox(width: 4),
|
|
// Viewer count text skeleton
|
|
SkeletonText(height: 14, minWidth: 30, maxWidth: 80),
|
|
],
|
|
if (showCategory && (showUptime || showViewerCount)) ...[
|
|
const SizedBox(width: 8),
|
|
],
|
|
if (showCategory) ...[
|
|
// Category icon skeleton (gamepad)
|
|
const SkeletonLoader(
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: BorderRadius.all(Radius.circular(2)),
|
|
),
|
|
const SizedBox(width: 4),
|
|
// Category text skeleton (flexible)
|
|
Flexible(
|
|
child: SkeletonText(
|
|
height: 14,
|
|
minWidth: 60,
|
|
maxWidth: 140,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for large stream cards (grid layout)
|
|
class LargeStreamCardSkeletonLoader extends StatelessWidget {
|
|
final bool showThumbnail;
|
|
final bool showCategory;
|
|
|
|
const LargeStreamCardSkeletonLoader({
|
|
super.key,
|
|
this.showThumbnail = true,
|
|
this.showCategory = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
top: showThumbnail ? 12 : 4,
|
|
bottom: showThumbnail ? 12 : 4,
|
|
left: 16 + MediaQuery.of(context).padding.left,
|
|
right: 16 + MediaQuery.of(context).padding.right,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
if (showThumbnail) ...[
|
|
// Thumbnail skeleton (16:9 aspect ratio)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ClipRRect(
|
|
borderRadius: kCardBorderRadius,
|
|
child: AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: const SkeletonLoader(
|
|
borderRadius: kCardBorderRadius,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// Stream info bar skeleton - matches the actual StreamInfoBar usage
|
|
StreamInfoBarSkeletonLoader(
|
|
showCategory: showCategory,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A skeleton loader for offline channel cards
|
|
class OfflineChannelCardSkeletonLoader extends StatelessWidget {
|
|
const OfflineChannelCardSkeletonLoader({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 16 + MediaQuery.of(context).padding.left,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
spacing: 12,
|
|
children: [
|
|
// Profile picture skeleton (radius 24 = 48px diameter)
|
|
const SkeletonLoader(
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: BorderRadius.all(Radius.circular(24)),
|
|
),
|
|
// Channel info skeleton
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
spacing: 8,
|
|
children: [
|
|
// Channel name skeleton
|
|
SkeletonText(height: 16, minWidth: 80, maxWidth: 160),
|
|
// Following duration skeleton
|
|
SkeletonText(height: 14, minWidth: 100, maxWidth: 140),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|