/* * Copyright (c) 2024-2026 Fabian Freund. * * This file is part of WebLibre * (see https://weblibre.eu). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; class BrowserPage extends ConsumerWidget { final Widget child; const BrowserPage({super.key, required this.child}); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final appColors = AppColors.of(context); return Stack( fit: StackFit.expand, children: [ // The aura backdrop is static: it only changes when the theme does. // Isolating it in a repaint boundary keeps it out of the scrolling // content's repaints, and the picture itself is raster-cacheable. RepaintBoundary( child: CustomPaint( painter: _AuraBackdropPainter( colorScheme: colorScheme, appColors: appColors, ), isComplex: true, willChange: false, ), ), Positioned.fill(child: child), ], ); } } /// The decorative background shared by the browser home and the onboarding /// pages: a diagonal wash with three soft coloured orbs bleeding in from the /// edges. /// /// This used to be three solid circles under a full-viewport /// `BackdropFilter(ImageFilter.blur(sigma: 72))`. That cost a save-layer plus a /// multi-pass gaussian blur of the entire screen *on every frame* — /// `BackdropFilter` re-reads and re-blurs its backdrop unconditionally and is /// never raster-cached — to soften artwork that never moves. Above the GeckoView /// platform view it was worse still, forcing the Android external view embedder /// to split the frame into extra overlay surfaces. /// /// A blurred disc is, to the eye, exactly a radial gradient, so the orbs are /// drawn as gradients directly. No save-layers, no blur passes, and the whole /// backdrop reduces to four shader-filled rects. class _AuraBackdropPainter extends CustomPainter { final ColorScheme colorScheme; final AppColors appColors; const _AuraBackdropPainter({ required this.colorScheme, required this.appColors, }); /// Sigma the orbs were previously blurred with. Retained as the falloff width /// so the gradients match the look the blur produced. static const _sigma = 72.0; @override void paint(Canvas canvas, Size size) { final bounds = Offset.zero & size; canvas.drawRect( bounds, Paint() ..shader = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color.alphaBlend( appColors.auraPurple.withValues(alpha: 0.38), colorScheme.surfaceContainerLowest, ), Color.alphaBlend( appColors.auraShadow.withValues(alpha: 0.72), colorScheme.surface, ), Color.alphaBlend( appColors.auraGold.withValues(alpha: 0.34), colorScheme.surfaceContainerHigh, ), ], ).createShader(bounds), ); // Centres and radii are the previous `Positioned` orbs resolved against the // viewport: 400² at (top: -70, left: -120), 340² at (top: 220, right: -150) // and 320² at (bottom: 18, left: -8). _paintOrb(canvas, bounds, const Offset(80, 130), 200, appColors.auraPurple); _paintOrb( canvas, bounds, Offset(size.width - 20, 390), 170, appColors.auraGold, ); _paintOrb( canvas, bounds, Offset(152, size.height - 178), 160, appColors.auraShadowHighlight, ); canvas.drawRect( bounds, Paint()..color = appColors.auraTint.withValues(alpha: 0.12), ); } void _paintOrb( Canvas canvas, Rect bounds, Offset center, double radius, Color color, ) { // Blur energy is spent by 2σ past the edge, so that is where the gradient // ends. final gradientRadius = radius + 2 * _sigma; // A gaussian-blurred disc holds an alpha of `1 - exp(-r²/2σ²)` at its // centre and falls off across the edge along the blur's error function, // which is ~0.98/0.84/0.5/0.16/0 at -2σ/-σ/0/+σ/+2σ relative to the edge. // Sampling those five points reproduces the blur closely enough that the // difference is invisible at this scale. final centerAlpha = 1 - math.exp(-(radius * radius) / (2 * _sigma * _sigma)); const falloff = [0.977, 0.841, 0.5, 0.159, 0.0]; final colors = [color.withValues(alpha: centerAlpha)]; final stops = [0.0]; for (var i = 0; i < falloff.length; i++) { final sampleRadius = radius + (i - 2) * _sigma; if (sampleRadius <= 0) { // The disc is smaller than the blur reaches inward; the samples that // fall inside the centre are already covered by [centerAlpha]. continue; } colors.add( // Clamped so the profile stays monotonically fading outward for small // discs, where the edge samples would otherwise exceed the centre. color.withValues(alpha: math.min(falloff[i], centerAlpha)), ); stops.add(sampleRadius / gradientRadius); } canvas.drawRect( bounds, Paint() ..shader = RadialGradient( colors: colors, stops: stops, ).createShader(Rect.fromCircle(center: center, radius: gradientRadius)), ); } @override bool shouldRepaint(_AuraBackdropPainter oldDelegate) => oldDelegate.colorScheme != colorScheme || oldDelegate.appColors != appColors; } class BrowserPageContent extends StatelessWidget { final double bottomViewportInset; final Widget child; const BrowserPageContent({ super.key, this.bottomViewportInset = 0, required this.child, }); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB(24, 32, 24, 32 + bottomViewportInset), child: ConstrainedBox( constraints: BoxConstraints( minHeight: math.max( 0, constraints.maxHeight - 64 - bottomViewportInset, ), ), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: child, ), ), ), ); }, ); } } class BrandHeader extends StatelessWidget { final ColorScheme colorScheme; const BrandHeader({super.key, required this.colorScheme}); @override Widget build(BuildContext context) { return Container( width: 112, height: 112, padding: const EdgeInsets.all(20), decoration: BoxDecoration( borderRadius: BorderRadius.circular(32), // Enough tint to read as brand colours: below roughly a quarter the // blend lands on a neutral grey and the mark looks like a placeholder. gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Color.alphaBlend( AppColors.brandPurple.withValues(alpha: 0.28), colorScheme.surfaceContainerHighest, ), Color.alphaBlend( AppColors.brandYellow.withValues(alpha: 0.20), colorScheme.surfaceContainer, ), ], ), border: Border.all( color: colorScheme.outlineVariant.withValues(alpha: 0.45), ), boxShadow: [ BoxShadow( color: colorScheme.shadow.withValues(alpha: 0.08), blurRadius: 32, offset: const Offset(0, 18), ), ], ), // 56 inside a 112 tile with 20 of padding: at 72 the mark exactly fills // the content box and its arms touch the tile edge, which reads as a // cropped image rather than a logo. child: Center( child: SvgPicture.asset('assets/icon/icon.svg', width: 56, height: 56), ), ); } }