From 046b7926c4f4f395b0c674fcd3f3ad2af06e87f6 Mon Sep 17 00:00:00 2001 From: zypherift Date: Tue, 3 Mar 2026 19:59:49 +0100 Subject: [PATCH] nightmare: add simulation from scratch for collision and movement --- .../ui/phone/screens/login/login_screen.dart | 606 +++++++++++++++--- firka/pubspec.yaml | 4 + 2 files changed, 527 insertions(+), 83 deletions(-) diff --git a/firka/lib/ui/phone/screens/login/login_screen.dart b/firka/lib/ui/phone/screens/login/login_screen.dart index 4613be0..28ecce9 100644 --- a/firka/lib/ui/phone/screens/login/login_screen.dart +++ b/firka/lib/ui/phone/screens/login/login_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:carousel_slider/carousel_slider.dart'; @@ -5,9 +6,13 @@ import 'package:firka/core/firka_bundle.dart'; import 'package:firka/app/app_state.dart'; import 'package:firka/ui/phone/widgets/login_webview.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:sensors_plus/sensors_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:vibration/vibration.dart'; import 'package:firka/core/bloc/theme_cubit.dart'; import 'package:firka/core/state/firka_state.dart'; @@ -15,16 +20,13 @@ import 'package:firka/core/image_preloader.dart'; import 'package:firka/ui/theme/style.dart'; import 'package:firka/ui/shared/delayed_spinner.dart'; -// TODO: Replace these with actual privacy policy URLs const String _privacyUrlHungarian = 'https://github.com/QwIT-Development/privacy-policy/blob/master/README.md'; const String _privacyUrlOther = 'https://firka.app/privacy'; class LoginScreen extends StatefulWidget { final AppInitialization data; - const LoginScreen(this.data, {super.key}); - @override State createState() => _LoginScreenState(); } @@ -36,22 +38,16 @@ class _LoginScreenState extends FirkaState { @override void initState() { super.initState(); - _loginWebView = LoginWebviewWidget(widget.data); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - _preloadImages(); } - // Method to get the appropriate privacy policy URL based on language String _getPrivacyPolicyUrl() { - // Check if current language is Hungarian by examining the locale final locale = Localizations.localeOf(context).languageCode; return locale == 'hu' ? _privacyUrlHungarian : _privacyUrlOther; } - // Method to launch privacy policy URL Future _launchPrivacyPolicy() async { final url = _getPrivacyPolicyUrl(); try { @@ -73,10 +69,8 @@ class _LoginScreenState extends FirkaState { "assets/images/carousel_dark/slide4.webp", "assets/images/logos/colored_logo.webp", ]; - try { await ImagePreloader.preloadMultipleAssets(FirkaBundle(), imagePaths); - setState(() { _preloadDone = true; }); @@ -116,6 +110,7 @@ class _LoginScreenState extends FirkaState { final paddingWidthHorizontal = MediaQuery.of(context).size.width - MediaQuery.of(context).size.width * 0.95; + List> slides = [ { 'title': widget.data.l10n.title1, @@ -124,7 +119,6 @@ class _LoginScreenState extends FirkaState { 'background': 'assets/images/carousel/slide1_background.webp', 'foreground': '', 'rotation': 180.00, - // „Mi nekünk két szám típusunk van, int (egy 32 bites szám) meg a double (egy 64 bites tört szám), KURVA ANYÁDAT” 'scale': 1.5, 'x': 0.00, 'y': 150.00, @@ -136,7 +130,6 @@ class _LoginScreenState extends FirkaState { 'background': 'assets/images/carousel/slide2_background.webp', 'foreground': '', 'rotation': 180.00, - //Mivel radiáns, és nullával nem lehet osztani (remélem tudtad), ezért ha eggyel osztunk akkor egy marad 'scale': 1.55, 'x': 10.00, 'y': 160.00, @@ -155,15 +148,15 @@ class _LoginScreenState extends FirkaState { { 'title': widget.data.l10n.title4, 'subtitle': widget.data.l10n.subtitle4, - 'picture': 'assets/images/$carousel/slide4.webp', + 'picture': '', 'background': 'assets/images/carousel/slide4_background.webp', 'foreground': '', 'rotation': 180.00, 'scale': 1.35, 'x': -5.00, 'y': 80.00, + 'cards': true, }, - //TODO: implement simulated physics so that the little boxes can move like the phone moves ]; return MaterialApp( @@ -211,43 +204,106 @@ class _LoginScreenState extends FirkaState { Expanded( child: CarouselSlider.builder( itemCount: slides.length, - itemBuilder: (context, index, realIndex) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsetsGeometry.symmetric( - horizontal: paddingWidthHorizontal, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - slides[index]['title']! as String, - style: appStyle.fonts.H_18px.copyWith( - color: appStyle.colors.textPrimary, + itemBuilder: (context, index, realIndex) { + final isCards = slides[index]['cards'] == true; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsetsGeometry.symmetric( + horizontal: paddingWidthHorizontal, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + slides[index]['title']! as String, + style: appStyle.fonts.H_18px.copyWith( + color: appStyle.colors.textPrimary, + ), + softWrap: true, + overflow: TextOverflow.visible, ), - softWrap: true, - overflow: TextOverflow.visible, - ), - const SizedBox(height: 8), - Text( - slides[index]['subtitle']! as String, - style: appStyle.fonts.B_16R.copyWith( - color: appStyle.colors.textPrimary, + const SizedBox(height: 8), + Text( + slides[index]['subtitle']! as String, + style: appStyle.fonts.B_16R.copyWith( + color: appStyle.colors.textPrimary, + ), + softWrap: true, + overflow: TextOverflow.visible, ), - softWrap: true, - overflow: TextOverflow.visible, - ), - ], + ], + ), ), - ), - Stack( - children: [ - slides[index]['background']! == '' - ? SizedBox() - : ClipRect( + if (isCards) + Expanded( + child: Stack( + children: [ + if ((slides[index]['background'] ?? '') + .toString() + .isNotEmpty) + ClipRect( + clipper: ImageClipper( + MediaQuery.of(context), + ), + child: Transform.rotate( + angle: + -math.pi / + (slides[index]['rotation']! + as double), + child: Transform.translate( + offset: Offset( + slides[index]['x'] as double, + slides[index]['y'] as double, + ), + child: SizedBox( + width: MediaQuery.of( + context, + ).size.width, + child: Transform.scale( + scale: + slides[index]['scale'] + as double, + child: Image.asset( + slides[index]['background']! + as String, + bundle: DefaultAssetBundle.of( + context, + ), + fit: BoxFit.contain, + width: double.infinity, + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8), + child: LayoutBuilder( + builder: (ctx, constraints) => + _FloatingCardsSlide( + width: constraints.maxWidth, + height: constraints.maxHeight, + topPadding: 30, + ), + ), + ), + ], + ), + ) + else + Stack( + children: [ + if ((slides[index]['background'] ?? '') + .toString() + .isNotEmpty) + ClipRect( clipper: ImageClipper( MediaQuery.of(context), ), @@ -283,31 +339,44 @@ class _LoginScreenState extends FirkaState { ), ), ), - Column( - children: [ - SizedBox(height: 73), - Padding( - padding: EdgeInsetsGeometry.symmetric( - horizontal: 18, - ), - child: SizedBox( - width: MediaQuery.of(context).size.width, - child: Image( - image: PreloadedImageProvider( - DefaultAssetBundle.of(context), - slides[index]['picture']! as String, + Column( + children: [ + const SizedBox(height: 73), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + // 10 padding each side + // LayoutBuilder receives the inset width so the physics walls match the edges + ), + child: SizedBox( + width: MediaQuery.of( + context, + ).size.width, + child: + (slides[index]['picture'] ?? '') + .toString() + .isNotEmpty + ? Image( + image: PreloadedImageProvider( + DefaultAssetBundle.of( + context, + ), + slides[index]['picture']! + as String, + ), + fit: BoxFit.cover, + width: double.infinity, + alignment: Alignment.center, + ) + : const SizedBox.shrink(), ), - fit: BoxFit.cover, - width: double.infinity, - alignment: Alignment.center, ), - ), + ], ), - ], - ), - slides[index]['foreground']! == '' - ? SizedBox() - : SizedBox( + if ((slides[index]['foreground'] ?? '') + .toString() + .isNotEmpty) + SizedBox( width: MediaQuery.of(context).size.width, child: ClipRect( clipBehavior: Clip.none, @@ -342,10 +411,11 @@ class _LoginScreenState extends FirkaState { ), ), ), - ], - ), - ], - ), + ], + ), + ], + ); + }, options: CarouselOptions( height: double.infinity, autoPlay: false, @@ -368,8 +438,8 @@ class _LoginScreenState extends FirkaState { colors: [ appStyle.colors.background.withAlpha(0), appStyle.colors.background, - ], // customize colors - stops: [0.0, 0.5], // percentages (0% → 50% → 100%) + ], + stops: const [0.0, 0.5], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), @@ -418,7 +488,7 @@ class _LoginScreenState extends FirkaState { 13, ), blurRadius: 2, - offset: Offset(0, 1), + offset: const Offset(0, 1), spreadRadius: 0, ), ], @@ -429,7 +499,9 @@ class _LoginScreenState extends FirkaState { textAlign: TextAlign.center, style: appStyle.fonts.H_16px.copyWith( color: appStyle.colors.textPrimaryLight, - fontVariations: [FontVariation("wght", 800)], + fontVariations: const [ + FontVariation("wght", 800), + ], ), ), ), @@ -459,10 +531,380 @@ class _LoginScreenState extends FirkaState { } } +//card config +class _CardConfig { + final String asset; + final Offset baseOffset; + final double size; + final double parallax; + final double glide; + final double aspect; + + const _CardConfig({ + required this.asset, + required this.baseOffset, + required this.size, + required this.parallax, + required this.glide, + this.aspect = 0.72, + }); +} + +//floating card part +class _FloatingCardsSlide extends StatefulWidget { + final double width; + final double height; + final double topPadding; + const _FloatingCardsSlide({ + required this.width, + required this.height, + this.topPadding = 0, + }); + @override + State<_FloatingCardsSlide> createState() => _FloatingCardsSlideState(); +} + +class _FloatingCardsSlideState extends State<_FloatingCardsSlide> + with SingleTickerProviderStateMixin { + static const double _friction = 0.878; + static const double _cardHeight = + 45; //not in pixels, idk what unit but it works :p + static const double _maxSpeed = _cardHeight * 1.2; + static const double _tiltForce = 0.05; + static const double _bounceDamping = 0.45; + static const double _collisionRestitution = 1.0; + + //minimum speed it has to go to trigger a vibration, so the phone doesn't turn into a bomb if the cards are touching + static const double _vibrateSpeedThreshold = _maxSpeed * 0.2; + + static const List<_CardConfig> _cards = [ + _CardConfig( + asset: 'assets/images/carousel/card1.svg', + baseOffset: Offset(-100, -15), + size: _cardHeight, + parallax: 7.5, + glide: 1.05, + aspect: 4.48, + ), // viewBox 215x48 + _CardConfig( + asset: 'assets/images/carousel/card2.svg', + baseOffset: Offset(-8, -35), + size: _cardHeight, + parallax: 9.0, + glide: 1.12, + aspect: 2.25, + ), // viewBox 108x48 + _CardConfig( + asset: 'assets/images/carousel/card3.svg', + baseOffset: Offset(88, -5), + size: _cardHeight, + parallax: 7.0, + glide: 1.0, + aspect: 4.13, + ), // viewBox 198x48 + _CardConfig( + asset: 'assets/images/carousel/card4.svg', + baseOffset: Offset(-60, 55), + size: _cardHeight, + parallax: 9.5, + glide: 1.15, + aspect: 2.25, + ), // viewBox 108x48 + _CardConfig( + asset: 'assets/images/carousel/card5.svg', + baseOffset: Offset(52, 80), + size: _cardHeight, + parallax: 10.5, + glide: 1.18, + aspect: 3.02, + ), // viewBox 145x48 + _CardConfig( + asset: 'assets/images/carousel/card6.svg', + baseOffset: Offset(128, 18), + size: _cardHeight, + parallax: 7.5, + glide: 0.95, + aspect: 5.63, + ), // viewBox 270x48 + _CardConfig( + asset: 'assets/images/carousel/card7.svg', + baseOffset: Offset(-138, 22), + size: _cardHeight, + parallax: 8.5, + glide: 1.05, + aspect: 3.94, + ), // viewBox 189x48 + ]; + + late Ticker _ticker; + List _positions = []; + List _velocities = []; + + Offset _tilt = Offset.zero; + Offset? _baseline; + StreamSubscription? _accelerometerSub; + Duration? _lastTick; + + double _sceneWidth = 0; + double _sceneHeight = 0; + + //we needed a cooldown, so that again, the phone doesn't turn into a bomb | EDIT: actually this was fixed by the minimum speed, so we don't need it anymore + // DateTime? _lastVibration; + + Offset _clampVel(Offset v) => Offset( + v.dx.clamp(-_maxSpeed, _maxSpeed), + v.dy.clamp(-_maxSpeed, _maxSpeed), + ); + + void _maybeVibrate() { + // final now = DateTime.now(); + // if (_lastVibration != null && + // now.difference(_lastVibration!).inMilliseconds < 2) //first used 50 but it wasn't good enough, so now it's 2 + // return; + // _lastVibration = now; + Vibration.vibrate(duration: 20); + } + + @override + void initState() { + super.initState(); + + _positions = _cards + .map((c) => c.baseOffset + const Offset(0, 300)) + .toList(); + + final rng = math.Random(); + _velocities = List.generate(_cards.length, (i) { + final jitter = (rng.nextDouble() - 0.5) * 3.0; + return Offset(jitter, -9.0 * _cards[i].glide); + }); + + _ticker = createTicker(_tick)..start(); + + _accelerometerSub = accelerometerEventStream( + samplingPeriod: SensorInterval.gameInterval, + ).listen(_handleTilt); + } + + void _handleTilt(AccelerometerEvent event) { + final raw = Offset(event.x, event.y); + _baseline ??= raw; + final rel = raw - _baseline!; + _tilt = Offset( + (_tilt.dx * 0.88 + rel.dx * 0.12).clamp(-6.5, 6.5), + (_tilt.dy * 0.88 + rel.dy * 0.12).clamp(-6.5, 6.5), + ); + } + + void _tick(Duration elapsed) { + if (_positions.isEmpty || _velocities.isEmpty) return; + if (_lastTick == null) { + _lastTick = elapsed; + return; + } + + final dt = ((elapsed - _lastTick!).inMicroseconds / 16667.0).clamp( + 0.0, + 4.0, + ); + _lastTick = elapsed; + + if (_sceneWidth == 0 || _sceneHeight == 0) return; + + bool collidedThisTick = false; + + setState(() { + final slope = Offset(-_tilt.dx, _tilt.dy); + final n = _cards.length; + + //friction + for (int i = 0; i < n; i++) { + final card = _cards[i]; + _velocities[i] += slope * card.parallax * _tiltForce * dt; + _velocities[i] *= math.pow(_friction, dt).toDouble(); + _velocities[i] = _clampVel(_velocities[i]); + _positions[i] += _velocities[i] * dt; + } + + // card to card collison and wall stuff + // + // running both together in a loop means that when card a is against a + // wall and card b pushes into it, the wall clamp on the a card. + // it goes back through the collision math wizard on the next loop, + // so car b receives the correct reaction instead of having a mating session with card a. + // five times is enough i think, more on slower end devices might cause issues idk tho + final double halfW = _sceneWidth / 2; + final double halfH = _sceneHeight / 2; + + for (int iter = 0; iter < 5; iter++) { + //here's the card to card magic, meow i'm going crazy :3 + for (int i = 0; i < n - 1; i++) { + for (int j = i + 1; j < n; j++) { + final wi = _cards[i].size * _cards[i].aspect; + final wj = _cards[j].size * _cards[j].aspect; + final hi = _cards[i].size; + final hj = _cards[j].size; + + final pi = _positions[i]; + final pj = _positions[j]; + + final overlapX = (wi + wj) / 2 - (pj.dx - pi.dx).abs(); + final overlapY = (hi + hj) / 2 - (pj.dy - pi.dy).abs(); + + if (overlapX > 0 && overlapY > 0) { + if (overlapX < overlapY) { + final sign = pj.dx > pi.dx ? 1.0 : -1.0; + _positions[i] = Offset(pi.dx - sign * overlapX / 2, pi.dy); + _positions[j] = Offset(pj.dx + sign * overlapX / 2, pj.dy); + + final viX = _velocities[i].dx; + final vjX = _velocities[j].dx; + if ((viX - vjX) * sign > 0) { + final impulse = (viX - vjX) * _collisionRestitution; + _velocities[i] = _clampVel( + Offset(_velocities[i].dx - impulse, _velocities[i].dy), + ); + _velocities[j] = _clampVel( + Offset(_velocities[j].dx + impulse, _velocities[j].dy), + ); + if ((viX - vjX).abs() > _vibrateSpeedThreshold) { + collidedThisTick = true; + } + } + } else { + final sign = pj.dy > pi.dy ? 1.0 : -1.0; + _positions[i] = Offset(pi.dx, pi.dy - sign * overlapY / 2); + _positions[j] = Offset(pj.dx, pj.dy + sign * overlapY / 2); + + final viY = _velocities[i].dy; + final vjY = _velocities[j].dy; + if ((viY - vjY) * sign > 0) { + final impulse = (viY - vjY) * _collisionRestitution; + _velocities[i] = _clampVel( + Offset(_velocities[i].dx, _velocities[i].dy - impulse), + ); + _velocities[j] = _clampVel( + Offset(_velocities[j].dx, _velocities[j].dy + impulse), + ); + if ((viY - vjY).abs() > _vibrateSpeedThreshold) { + collidedThisTick = true; + } + } + } + } + } + } + + // wall collision, runs every loop, explained before + // feeds back into the next collision loop. + for (int i = 0; i < n; i++) { + final card = _cards[i]; + final double cardW = card.size * card.aspect; + final double cardH = card.size; + + final double minX = -halfW + cardW / 2; + final double maxX = halfW - cardW / 2; + final double minY = -halfH + cardH / 2; + final double maxY = halfH - cardH / 2; + + Offset pos = _positions[i]; + double vx = _velocities[i].dx; + double vy = _velocities[i].dy; + + if (pos.dx < minX) { + pos = Offset(minX, pos.dy); + vx = vx.abs() * _bounceDamping; + } else if (pos.dx > maxX) { + pos = Offset(maxX, pos.dy); + vx = -vx.abs() * _bounceDamping; + } + + if (pos.dy < minY) { + pos = Offset(pos.dx, minY); + vy = vy.abs() * _bounceDamping; + } else if (pos.dy > maxY) { + pos = Offset(pos.dx, maxY); + vy = -vy.abs() * _bounceDamping; + } + + _velocities[i] = _clampVel(Offset(vx, vy)); + _positions[i] = pos; + } + } + }); + + // phone mating session + if (collidedThisTick) _maybeVibrate(); + } + + @override + void dispose() { + _ticker.dispose(); + _accelerometerSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double totalHeight = widget.height; + _sceneWidth = widget.width; + _sceneHeight = math.max(0, totalHeight - widget.topPadding); + + final Offset center = Offset( + _sceneWidth / 2, + widget.topPadding + _sceneHeight / 2, + ); + + return SizedBox( + width: _sceneWidth, + height: totalHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + //background rectangle + Positioned( + left: 0, + right: 0, + top: widget.topPadding, + bottom: 0, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.buttonSecondaryFill, //button color xdddd + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + ), + ), + ...List.generate(_cards.length, (i) { + final card = _cards[i]; + final double cardWidth = card.size * card.aspect; + final double cardHeight = card.size; + final Offset pos = + center + _positions[i] - Offset(cardWidth / 2, cardHeight / 2); + return Positioned( + left: pos.dx, + top: pos.dy, + width: cardWidth, + height: cardHeight, + child: SvgPicture.asset( + card.asset, + width: cardWidth, + height: cardHeight, + fit: BoxFit.contain, + ), + ); + }), + ], + ), + ); + } +} + // this sucks :3 class ImageClipper extends CustomClipper { final MediaQueryData _mediaQuery; - ImageClipper(this._mediaQuery); @override @@ -476,7 +918,5 @@ class ImageClipper extends CustomClipper { } @override - bool shouldReclip(covariant CustomClipper oldClipper) { - return false; - } + bool shouldReclip(covariant CustomClipper oldClipper) => false; } diff --git a/firka/pubspec.yaml b/firka/pubspec.yaml index b60ee83..b0bcd0e 100644 --- a/firka/pubspec.yaml +++ b/firka/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: live_activities: ^2.4.1 logging: ^1.3.0 share_plus: ^12.0.0 + sensors_plus: ^6.1.1 + flutter_physics: ^0.2.0 url_launcher: ^6.3.2 shared_preferences: ^2.3.4 flutter_dotenv: ^5.2.1 @@ -55,6 +57,7 @@ dependencies: flutter_native_splash: ^2.4.7 go_router: ^17.1.0 flutter_bloc: ^9.0.0 + vibration: ^3.1.8 dev_dependencies: flutter_test: @@ -88,6 +91,7 @@ flutter: - assets/swears/DirtyWords.xml - assets/images/cactus_error_screen.png - assets/images/logos/dave_error.png + - assets/icons/button/colorwheel.png fonts: - family: Montserrat