diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index 574ca23..93d8d28 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -328,6 +329,8 @@ class _MapboxMapLayer extends StatefulWidget { } class _MapboxMapLayerState extends State<_MapboxMapLayer> { + static const _worldCenter = LatLng(8.0, 32.0); + mbx.MapboxMap? _mapboxMap; var _mapLoaded = false; var _didInitialFlyIn = false; @@ -351,12 +354,19 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> { mbx.MapWidget( styleUri: _mapboxStyleUri, viewport: mbx.CameraViewportState( - center: _mapboxPoint(widget.initialCenter), - zoom: _didInitialFlyIn ? 14.8 : 1.25, - bearing: _didInitialFlyIn ? -16 : 0, + center: _mapboxPoint( + _didInitialFlyIn ? widget.initialCenter : _worldCenter, + ), + zoom: _didInitialFlyIn ? 14.8 : 0.58, + bearing: _didInitialFlyIn ? -16 : -24, pitch: _didInitialFlyIn ? 62 : 0, ), onMapCreated: _onMapCreated, + onStyleLoadedListener: (_) { + if (!kIsWeb) { + unawaited(_enableGlobeProjection()); + } + }, onMapLoadedListener: (_) { _mapLoaded = true; _updateCameraForFocus(); @@ -392,6 +402,16 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> { _mapboxMap = mapboxMap; } + Future _enableGlobeProjection() async { + final mapboxMap = _mapboxMap; + if (mapboxMap == null) { + return; + } + await mapboxMap.style.setProjection( + mbx.StyleProjection(name: mbx.StyleProjectionName.globe), + ); + } + void _updateCameraForFocus() { final mapboxMap = _mapboxMap; final focusCenter = widget.focusCenter; @@ -417,7 +437,12 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> { } _didInitialFlyIn = true; - unawaited(mapboxMap.flyTo(camera, mbx.MapAnimationOptions(duration: 2600))); + unawaited( + mapboxMap.flyTo( + camera, + mbx.MapAnimationOptions(duration: 4200, startDelay: 350), + ), + ); } Future _projectMarkers() async { @@ -968,40 +993,467 @@ class _TelegramLoginScreenState extends State<_TelegramLoginScreen> { @override Widget build(BuildContext context) { + final tokens = context.mapflowTokens; + return Scaffold( - body: SafeArea( - child: Center( - child: SizedBox( - width: 320, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'MapFlow', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.w900, - letterSpacing: 0, - ), + backgroundColor: tokens.darkSurface, + body: Stack( + children: [ + const Positioned.fill(child: _AnimatedEarthBackdrop()), + SafeArea( + child: Center( + child: SizedBox( + width: 320, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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 { const _MapLoading(); @@ -1010,43 +1462,48 @@ class _MapLoading extends StatelessWidget { final tokens = context.mapflowTokens; return Scaffold( backgroundColor: tokens.darkSurface, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(tokens.panelRadius), - child: Image.network( - 'icons/Icon-192.png', - width: 64, - height: 64, - errorBuilder: (_, _, _) => Icon( - Icons.map_outlined, - color: tokens.voiceAccent, - size: 56, + body: Stack( + children: [ + const Positioned.fill(child: _AnimatedEarthBackdrop(compact: true)), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(tokens.panelRadius), + child: Image.network( + 'icons/Icon-192.png', + width: 64, + height: 64, + 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), - ), - ), - ], - ), + ), + ], ), ); }