Polish earth login animation
Build and deploy Flutter Web / build (push) Successful in 4m2s

This commit is contained in:
Ruslan Bakiev
2026-06-24 11:45:37 +07:00
parent 2989ca7b2b
commit 764e93df59
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@@ -328,6 +329,8 @@ class _MapboxMapLayer extends StatefulWidget {
} }
class _MapboxMapLayerState extends State<_MapboxMapLayer> { class _MapboxMapLayerState extends State<_MapboxMapLayer> {
static const _worldCenter = LatLng(8.0, 32.0);
mbx.MapboxMap? _mapboxMap; mbx.MapboxMap? _mapboxMap;
var _mapLoaded = false; var _mapLoaded = false;
var _didInitialFlyIn = false; var _didInitialFlyIn = false;
@@ -351,12 +354,19 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
mbx.MapWidget( mbx.MapWidget(
styleUri: _mapboxStyleUri, styleUri: _mapboxStyleUri,
viewport: mbx.CameraViewportState( viewport: mbx.CameraViewportState(
center: _mapboxPoint(widget.initialCenter), center: _mapboxPoint(
zoom: _didInitialFlyIn ? 14.8 : 1.25, _didInitialFlyIn ? widget.initialCenter : _worldCenter,
bearing: _didInitialFlyIn ? -16 : 0, ),
zoom: _didInitialFlyIn ? 14.8 : 0.58,
bearing: _didInitialFlyIn ? -16 : -24,
pitch: _didInitialFlyIn ? 62 : 0, pitch: _didInitialFlyIn ? 62 : 0,
), ),
onMapCreated: _onMapCreated, onMapCreated: _onMapCreated,
onStyleLoadedListener: (_) {
if (!kIsWeb) {
unawaited(_enableGlobeProjection());
}
},
onMapLoadedListener: (_) { onMapLoadedListener: (_) {
_mapLoaded = true; _mapLoaded = true;
_updateCameraForFocus(); _updateCameraForFocus();
@@ -392,6 +402,16 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
_mapboxMap = mapboxMap; _mapboxMap = mapboxMap;
} }
Future<void> _enableGlobeProjection() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) {
return;
}
await mapboxMap.style.setProjection(
mbx.StyleProjection(name: mbx.StyleProjectionName.globe),
);
}
void _updateCameraForFocus() { void _updateCameraForFocus() {
final mapboxMap = _mapboxMap; final mapboxMap = _mapboxMap;
final focusCenter = widget.focusCenter; final focusCenter = widget.focusCenter;
@@ -417,7 +437,12 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
} }
_didInitialFlyIn = true; _didInitialFlyIn = true;
unawaited(mapboxMap.flyTo(camera, mbx.MapAnimationOptions(duration: 2600))); unawaited(
mapboxMap.flyTo(
camera,
mbx.MapAnimationOptions(duration: 4200, startDelay: 350),
),
);
} }
Future<void> _projectMarkers() async { Future<void> _projectMarkers() async {
@@ -968,40 +993,467 @@ class _TelegramLoginScreenState extends State<_TelegramLoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return Scaffold( return Scaffold(
body: SafeArea( backgroundColor: tokens.darkSurface,
child: Center( body: Stack(
child: SizedBox( children: [
width: 320, const Positioned.fill(child: _AnimatedEarthBackdrop()),
child: Column( SafeArea(
mainAxisSize: MainAxisSize.min, child: Center(
children: [ child: SizedBox(
Text( width: 320,
'MapFlow', child: Column(
style: Theme.of(context).textTheme.headlineMedium?.copyWith( mainAxisSize: MainAxisSize.min,
fontWeight: FontWeight.w900, children: [
letterSpacing: 0, Text(
), 'MapFlow',
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
color: tokens.onDark,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 24),
TelegramLoginButton(
onPressed: _startLogin,
loading: _loading,
),
if (_message.isNotEmpty) ...[
const SizedBox(height: 14),
Text(
_message,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: tokens.onDark),
),
],
],
), ),
const SizedBox(height: 24), ),
TelegramLoginButton(onPressed: _startLogin, loading: _loading),
if (_message.isNotEmpty) ...[
const SizedBox(height: 14),
Text(
_message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
), ),
), ),
), ],
), ),
); );
} }
} }
class _AnimatedEarthBackdrop extends StatefulWidget {
const _AnimatedEarthBackdrop({this.compact = false});
final bool compact;
@override
State<_AnimatedEarthBackdrop> createState() => _AnimatedEarthBackdropState();
}
class _AnimatedEarthBackdropState extends State<_AnimatedEarthBackdrop>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 18),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _EarthBackdropPainter(
progress: _controller.value,
tokens: context.mapflowTokens,
compact: widget.compact,
),
child: const SizedBox.expand(),
);
},
),
);
}
}
class _EarthBackdropPainter extends CustomPainter {
const _EarthBackdropPainter({
required this.progress,
required this.tokens,
required this.compact,
});
final double progress;
final MapflowThemeTokens tokens;
final bool compact;
static const _landMasses = <({double lon, double lat, double sx, double sy})>[
(lon: -106, lat: 42, sx: 0.30, sy: 0.18),
(lon: -82, lat: 18, sx: 0.20, sy: 0.14),
(lon: -62, lat: -14, sx: 0.22, sy: 0.25),
(lon: 16, lat: 8, sx: 0.23, sy: 0.31),
(lon: 46, lat: 48, sx: 0.34, sy: 0.18),
(lon: 78, lat: 25, sx: 0.32, sy: 0.20),
(lon: 116, lat: -23, sx: 0.19, sy: 0.13),
(lon: 137, lat: 36, sx: 0.10, sy: 0.07),
(lon: -42, lat: 73, sx: 0.16, sy: 0.08),
];
static const _routes =
<({double fromLon, double fromLat, double toLon, double toLat})>[
(fromLon: -74, fromLat: 41, toLon: 2, toLat: 49),
(fromLon: 2, fromLat: 49, toLon: 105, toLat: 16),
(fromLon: 139, fromLat: 35, toLon: 105, toLat: 16),
(fromLon: -122, fromLat: 37, toLon: 139, toLat: 35),
];
@override
void paint(Canvas canvas, Size size) {
_paintSpace(canvas, size);
_paintOrbits(canvas, size);
_paintEarth(canvas, size);
}
void _paintSpace(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
tokens.darkSurface,
const Color(0xFF07151B),
const Color(0xFF150918),
tokens.darkSurface,
],
).createShader(rect),
);
final glowCenter = Offset(size.width * 0.77, size.height * 0.18);
canvas.drawCircle(
glowCenter,
math.max(size.width, size.height) * 0.48,
Paint()
..shader =
RadialGradient(
colors: [
const Color(0xFFFFB86B).withValues(alpha: 0.20),
tokens.voiceAccent.withValues(alpha: 0.08),
Colors.transparent,
],
stops: const [0, 0.34, 1],
).createShader(
Rect.fromCircle(
center: glowCenter,
radius: math.max(size.width, size.height) * 0.48,
),
),
);
final stars = Paint()..color = Colors.white;
for (var index = 0; index < 96; index++) {
final xSeed = math.sin(index * 12.9898) * 43758.5453;
final ySeed = math.sin(index * 78.233) * 24634.6345;
final x = (xSeed - xSeed.floor()) * size.width;
final y = (ySeed - ySeed.floor()) * size.height;
final twinkle = 0.35 + 0.65 * math.sin(progress * math.pi * 2 + index);
stars.color = Colors.white.withValues(alpha: 0.10 + twinkle * 0.28);
canvas.drawCircle(Offset(x, y), 0.7 + (index % 3) * 0.35, stars);
}
}
void _paintOrbits(Canvas canvas, Size size) {
final center = _earthCenter(size);
final radius = _earthRadius(size);
final orbitPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.1
..color = const Color(0xFF5BE7C4).withValues(alpha: 0.15);
for (final scale in const [1.28, 1.48, 1.72]) {
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(-0.46 + progress * math.pi * 0.18);
canvas.scale(1, 0.34);
canvas.drawCircle(Offset.zero, radius * scale, orbitPaint);
canvas.restore();
}
final satelliteAngle = progress * math.pi * 2;
final satellite = Offset(
center.dx + math.cos(satelliteAngle) * radius * 1.72,
center.dy + math.sin(satelliteAngle) * radius * 0.58,
);
canvas.drawCircle(
satellite,
3.6,
Paint()..color = const Color(0xFFFFD38A).withValues(alpha: 0.92),
);
}
void _paintEarth(Canvas canvas, Size size) {
final center = _earthCenter(size);
final radius = _earthRadius(size);
final sphere = Rect.fromCircle(center: center, radius: radius);
canvas.drawCircle(
center.translate(radius * 0.10, radius * 0.12),
radius * 1.06,
Paint()
..shader = RadialGradient(
colors: [Colors.black.withValues(alpha: 0.46), Colors.transparent],
).createShader(sphere.inflate(radius * 0.18)),
);
canvas.drawCircle(
center,
radius * 1.02,
Paint()
..shader = RadialGradient(
center: const Alignment(-0.48, -0.62),
colors: [
const Color(0xFF8AF8FF).withValues(alpha: 0.32),
const Color(0xFF124968).withValues(alpha: 0.72),
Colors.transparent,
],
stops: const [0, 0.64, 1],
).createShader(sphere.inflate(radius * 0.18)),
);
canvas.save();
canvas.clipPath(Path()..addOval(sphere));
canvas.drawOval(
sphere,
Paint()
..shader = RadialGradient(
center: const Alignment(-0.42, -0.50),
radius: 1.08,
colors: const [
Color(0xFF7AF6FF),
Color(0xFF1589B0),
Color(0xFF073456),
Color(0xFF030A1A),
],
stops: [0, 0.34, 0.72, 1],
).createShader(sphere),
);
_paintGrid(canvas, center, radius);
_paintLand(canvas, center, radius);
_paintRoutes(canvas, center, radius);
_paintTerminator(canvas, center, radius, sphere);
canvas.restore();
canvas.drawOval(
sphere,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = const Color(0xFFB7FFF5).withValues(alpha: 0.36),
);
}
void _paintGrid(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = Colors.white.withValues(alpha: 0.11);
for (final lat in const [-60, -30, 0, 30, 60]) {
final y = center.dy - math.sin(_radians(lat.toDouble())) * radius * 0.72;
final width = math.cos(_radians(lat.toDouble())) * radius;
canvas.drawOval(
Rect.fromCenter(
center: Offset(center.dx, y),
width: width * 2,
height: width * 0.38,
),
paint,
);
}
for (var meridian = 0; meridian < 6; meridian++) {
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate((meridian / 6) * math.pi + progress * math.pi * 2);
canvas.scale(0.20, 1);
canvas.drawCircle(Offset.zero, radius, paint);
canvas.restore();
}
}
void _paintLand(Canvas canvas, Offset center, double radius) {
final landPaint = Paint()
..color = const Color(0xFF4EDC9B).withValues(alpha: 0.86);
final highlightPaint = Paint()
..color = const Color(0xFFFFD38A).withValues(alpha: 0.34);
for (final land in _landMasses) {
final projected = _project(land.lon, land.lat, center, radius);
if (projected == null) {
continue;
}
final (:point, :depth) = projected;
final alpha = (0.30 + depth * 0.70).clamp(0.0, 1.0);
landPaint.color = const Color(0xFF4EDC9B).withValues(alpha: alpha * 0.88);
highlightPaint.color = const Color(
0xFFFFD38A,
).withValues(alpha: alpha * 0.26);
final landRect = Rect.fromCenter(
center: point,
width: radius * land.sx * (0.62 + depth * 0.58),
height: radius * land.sy * (0.56 + depth * 0.36),
);
canvas.save();
canvas.translate(point.dx, point.dy);
canvas.rotate(math.sin(land.lon) * 0.65);
canvas.translate(-point.dx, -point.dy);
canvas.drawOval(landRect, landPaint);
canvas.drawOval(landRect.deflate(radius * 0.018), highlightPaint);
canvas.restore();
}
}
void _paintRoutes(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 1.4
..color = tokens.voiceAccentSoft.withValues(alpha: 0.38);
final pulsePaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 2.8
..color = tokens.voiceAccent.withValues(alpha: 0.44);
for (var index = 0; index < _routes.length; index++) {
final route = _routes[index];
final from = _project(route.fromLon, route.fromLat, center, radius);
final to = _project(route.toLon, route.toLat, center, radius);
if (from == null || to == null) {
continue;
}
final path = Path()
..moveTo(from.point.dx, from.point.dy)
..quadraticBezierTo(
(from.point.dx + to.point.dx) / 2,
math.min(from.point.dy, to.point.dy) - radius * (0.18 + index * 0.03),
to.point.dx,
to.point.dy,
);
canvas.drawPath(path, paint);
final metric = path.computeMetrics().first;
final head = ((progress + index * 0.19) % 1) * metric.length;
final segment = metric.extractPath(
math.max(0, head - metric.length * 0.12),
head,
);
canvas.drawPath(segment, pulsePaint);
}
}
void _paintTerminator(
Canvas canvas,
Offset center,
double radius,
Rect sphere,
) {
canvas.drawOval(
sphere.shift(Offset(radius * 0.22, radius * 0.06)),
Paint()
..shader = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.18),
Colors.black.withValues(alpha: 0.68),
],
stops: const [0, 0.52, 1],
).createShader(sphere),
);
canvas.drawCircle(
center.translate(-radius * 0.28, -radius * 0.30),
radius * 0.22,
Paint()
..shader =
RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.22),
Colors.transparent,
],
).createShader(
Rect.fromCircle(
center: center.translate(-radius * 0.28, -radius * 0.30),
radius: radius * 0.22,
),
),
);
}
({Offset point, double depth})? _project(
double lon,
double lat,
Offset center,
double radius,
) {
final longitude = _radians(lon + progress * 360);
final latitude = _radians(lat);
final x = math.cos(latitude) * math.sin(longitude);
final z = math.cos(latitude) * math.cos(longitude);
if (z < -0.18) {
return null;
}
final y = -math.sin(latitude) * 0.72;
final depthScale = 0.72 + z * 0.28;
return (
point: Offset(
center.dx + x * radius * depthScale,
center.dy + y * radius * depthScale,
),
depth: ((z + 0.18) / 1.18).clamp(0.0, 1.0),
);
}
Offset _earthCenter(Size size) {
if (compact) {
return Offset(size.width * 0.50, size.height * 0.39);
}
return Offset(size.width * 0.50, size.height * 0.40);
}
double _earthRadius(Size size) {
final base = math.min(size.width, size.height);
final radius = base * (compact ? 0.18 : 0.31);
return radius.clamp(116.0, compact ? 180.0 : 310.0);
}
double _radians(double degrees) => degrees * math.pi / 180;
@override
bool shouldRepaint(covariant _EarthBackdropPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.tokens != tokens ||
oldDelegate.compact != compact;
}
}
class _MapLoading extends StatelessWidget { class _MapLoading extends StatelessWidget {
const _MapLoading(); const _MapLoading();
@@ -1010,43 +1462,48 @@ class _MapLoading extends StatelessWidget {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
return Scaffold( return Scaffold(
backgroundColor: tokens.darkSurface, backgroundColor: tokens.darkSurface,
body: Center( body: Stack(
child: Column( children: [
mainAxisSize: MainAxisSize.min, const Positioned.fill(child: _AnimatedEarthBackdrop(compact: true)),
children: [ Center(
ClipRRect( child: Column(
borderRadius: BorderRadius.circular(tokens.panelRadius), mainAxisSize: MainAxisSize.min,
child: Image.network( children: [
'icons/Icon-192.png', ClipRRect(
width: 64, borderRadius: BorderRadius.circular(tokens.panelRadius),
height: 64, child: Image.network(
errorBuilder: (_, _, _) => Icon( 'icons/Icon-192.png',
Icons.map_outlined, width: 64,
color: tokens.voiceAccent, height: 64,
size: 56, errorBuilder: (_, _, _) => Icon(
Icons.map_outlined,
color: tokens.voiceAccent,
size: 56,
),
),
), ),
), const SizedBox(height: 18),
Text(
'MapFlow',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: tokens.onDark,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 18),
SizedBox(
width: 96,
child: LinearProgressIndicator(
minHeight: 3,
color: tokens.voiceAccent,
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
),
),
],
), ),
const SizedBox(height: 18), ),
Text( ],
'MapFlow',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: tokens.onDark,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 18),
SizedBox(
width: 96,
child: LinearProgressIndicator(
minHeight: 3,
color: tokens.voiceAccent,
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
),
),
],
),
), ),
); );
} }