Introduce FrostyColors ThemeExtension with 7 semantic color fields (highlightedMessage, emoteOnly, followersOnly, slowMode, subOnly, uniqueMode, overlayOnSurface) and brightness-aware light/dark variants. Replace hardcoded colors across 15 files: - Links use colorScheme.primary instead of Colors.blue - Mentions use colorScheme.error instead of Colors.red - First-time chatters share highlightedMessage (Twitch purple) - Chat mode indicators use frostyColors fields - Overlay text uses frostyColors.overlayOnSurface Remove Provider<FrostyThemes> pattern from main.dart, channel.dart, video_overlay.dart, and frosty_photo_view_dialog.dart. Replace manual onSurface.withValues(alpha: 0.6) with M3's built-in onSurfaceVariant for secondary text in section headers, chat modes, alerts, loading indicators, and input hints. Fix double-muted onSurfaceVariant.withValues(alpha: 0.6) in navigation icons and notification dismiss button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
8.1 KiB
Dart
225 lines
8.1 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:frosty/cache_manager.dart';
|
|
import 'package:frosty/utils/context_extensions.dart';
|
|
import 'package:frosty/widgets/blurred_container.dart';
|
|
import 'package:photo_view/photo_view.dart';
|
|
|
|
class FrostyPhotoViewDialog extends StatefulWidget {
|
|
final String imageUrl;
|
|
final String? cacheKey;
|
|
|
|
const FrostyPhotoViewDialog({
|
|
super.key,
|
|
required this.imageUrl,
|
|
this.cacheKey,
|
|
});
|
|
|
|
@override
|
|
State<FrostyPhotoViewDialog> createState() => _FrostyPhotoViewDialogState();
|
|
}
|
|
|
|
class _FrostyPhotoViewDialogState extends State<FrostyPhotoViewDialog>
|
|
with TickerProviderStateMixin {
|
|
PhotoViewScaleState photoViewScaleState = PhotoViewScaleState.initial;
|
|
bool _isFullResolution = false;
|
|
String? _currentCacheKey;
|
|
|
|
// Vertical drag offset in pixels. Positive = dragging down.
|
|
double _dragOffset = 0.0;
|
|
late final AnimationController _resetController;
|
|
late Animation<double> _resetAnimation;
|
|
// Exit animation (translate in swipe direction + fade out) when dismissing
|
|
late final AnimationController _exitController;
|
|
late Animation<double> _exitTranslateAnimation;
|
|
late Animation<double> _exitOpacityAnimation;
|
|
double _imageOpacity = 1.0;
|
|
// Track swipe direction for dismissal animation
|
|
double _swipeDirection = 1.0; // 1.0 for down, -1.0 for up
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentCacheKey = widget.cacheKey;
|
|
_resetController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 200),
|
|
);
|
|
_resetAnimation = Tween<double>(begin: 0, end: 0).animate(_resetController)
|
|
..addListener(() => setState(() => _dragOffset = _resetAnimation.value));
|
|
_exitController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 220),
|
|
);
|
|
_exitTranslateAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(parent: _exitController, curve: Curves.easeOut),
|
|
)..addListener(() => setState(() {}));
|
|
_exitOpacityAnimation =
|
|
Tween<double>(begin: 1.0, end: 0.0).animate(
|
|
CurvedAnimation(parent: _exitController, curve: Curves.easeOut),
|
|
)..addListener(
|
|
() => setState(() => _imageOpacity = _exitOpacityAnimation.value),
|
|
);
|
|
}
|
|
|
|
void _toggleResolution() {
|
|
setState(() {
|
|
if (_isFullResolution) {
|
|
// Switch back to the original thumbnail version
|
|
_isFullResolution = false;
|
|
_currentCacheKey = widget.cacheKey;
|
|
} else {
|
|
// Load full resolution and clear cache key to force fresh fetch
|
|
_isFullResolution = true;
|
|
_currentCacheKey = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Create full resolution URL
|
|
final fullResUrl = widget.imageUrl.replaceFirst('-{width}x{height}', '');
|
|
// Only stream card thumbnails include the placeholder; emotes do not.
|
|
// If replacement yields the same URL, there is no full-res variant.
|
|
final supportsFullRes = fullResUrl != widget.imageUrl;
|
|
|
|
final screenHeight = MediaQuery.of(context).size.height;
|
|
final fadeDistance = screenHeight * 0.20; // 20% of screen height
|
|
final buttonsOpacity = (1.0 - (_dragOffset.abs() / fadeDistance)).clamp(
|
|
0.0,
|
|
1.0,
|
|
);
|
|
|
|
return Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: [
|
|
// Very weak blurred background behind the image (fills the screen)
|
|
BlurredContainer(
|
|
sigmaX: 4.0,
|
|
sigmaY: 4.0,
|
|
// Use a very low alpha so the blur is subtle
|
|
backgroundAlpha: 0,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
// Use a GestureDetector so we can track vertical drags and translate the
|
|
// PhotoView accordingly. When the photo is zoomed (not initial) we disable dragging.
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onVerticalDragStart: (_) {
|
|
if (_resetController.isAnimating) _resetController.stop();
|
|
// Reset swipe direction for new drag
|
|
_swipeDirection = 1.0;
|
|
},
|
|
onVerticalDragUpdate:
|
|
photoViewScaleState == PhotoViewScaleState.initial
|
|
? (details) => setState(() => _dragOffset += details.delta.dy)
|
|
: null,
|
|
onVerticalDragEnd: photoViewScaleState == PhotoViewScaleState.initial
|
|
? (details) {
|
|
final velocity = details.velocity.pixelsPerSecond.dy;
|
|
final shouldDismiss =
|
|
(_dragOffset.abs() > fadeDistance) ||
|
|
velocity.abs() > 700;
|
|
if (shouldDismiss) {
|
|
// Determine swipe direction based on drag offset and velocity
|
|
_swipeDirection = _dragOffset > 0 ? 1.0 : -1.0;
|
|
if (_dragOffset.abs() < 10 && velocity != 0) {
|
|
_swipeDirection = velocity > 0 ? 1.0 : -1.0;
|
|
}
|
|
// run exit animation: translate in swipe direction by 30% of screen and fade out
|
|
_exitController
|
|
..value = 0.0
|
|
..forward().whenComplete(
|
|
() => Navigator.of(context).pop(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// animate back to zero
|
|
_resetAnimation = Tween<double>(
|
|
begin: _dragOffset,
|
|
end: 0.0,
|
|
).animate(_resetController);
|
|
_resetController
|
|
..value = 0.0
|
|
..forward();
|
|
}
|
|
: null,
|
|
child: Transform.translate(
|
|
offset: Offset(
|
|
0,
|
|
_dragOffset +
|
|
(_exitTranslateAnimation.value *
|
|
screenHeight *
|
|
0.25 *
|
|
_swipeDirection),
|
|
),
|
|
child: Opacity(
|
|
opacity: _imageOpacity,
|
|
child: PhotoView(
|
|
imageProvider: CachedNetworkImageProvider(
|
|
_isFullResolution ? fullResUrl : widget.imageUrl,
|
|
cacheKey: _currentCacheKey,
|
|
cacheManager: CustomCacheManager.instance,
|
|
),
|
|
scaleStateChangedCallback: (value) =>
|
|
setState(() => photoViewScaleState = value),
|
|
backgroundDecoration: const BoxDecoration(
|
|
color: Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Close button that fades with drag
|
|
AnimatedOpacity(
|
|
opacity: buttonsOpacity,
|
|
duration: const Duration(milliseconds: 100),
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.close,
|
|
color: context.frostyColors.overlayOnSurface,
|
|
),
|
|
onPressed: Navigator.of(context).pop,
|
|
),
|
|
),
|
|
|
|
if (supportsFullRes)
|
|
Positioned(
|
|
// Place the button roughly between the image area and the bottom of the screen.
|
|
// Assumption: the photo view occupies the top portion of the screen; using
|
|
// a fractional top offset (80%) places the button in the gap between image and bottom.
|
|
top: MediaQuery.of(context).size.height * 0.8,
|
|
left: 0,
|
|
right: 0,
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Center(
|
|
child: AnimatedOpacity(
|
|
opacity: buttonsOpacity,
|
|
duration: const Duration(milliseconds: 100),
|
|
child: FilledButton.tonal(
|
|
onPressed: _toggleResolution,
|
|
child: Text(
|
|
_isFullResolution ? 'View thumbnail' : 'View original',
|
|
style: TextStyle(
|
|
color: context.frostyColors.overlayOnSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_resetController.dispose();
|
|
_exitController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|