1
0
forked from firka/firka

nightmare: add simulation from scratch for collision and movement

This commit is contained in:
zypherift
2026-03-03 19:59:49 +01:00
parent 5626466107
commit 046b7926c4
2 changed files with 527 additions and 83 deletions

View File

@@ -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<LoginScreen> createState() => _LoginScreenState();
}
@@ -36,22 +38,16 @@ class _LoginScreenState extends FirkaState<LoginScreen> {
@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<void> _launchPrivacyPolicy() async {
final url = _getPrivacyPolicyUrl();
try {
@@ -73,10 +69,8 @@ class _LoginScreenState extends FirkaState<LoginScreen> {
"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<LoginScreen> {
final paddingWidthHorizontal =
MediaQuery.of(context).size.width -
MediaQuery.of(context).size.width * 0.95;
List<Map<String, Object>> slides = [
{
'title': widget.data.l10n.title1,
@@ -124,7 +119,6 @@ class _LoginScreenState extends FirkaState<LoginScreen> {
'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<LoginScreen> {
'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<LoginScreen> {
{
'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<LoginScreen> {
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<LoginScreen> {
),
),
),
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<LoginScreen> {
),
),
),
],
),
],
),
],
),
],
);
},
options: CarouselOptions(
height: double.infinity,
autoPlay: false,
@@ -368,8 +438,8 @@ class _LoginScreenState extends FirkaState<LoginScreen> {
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<LoginScreen> {
13,
),
blurRadius: 2,
offset: Offset(0, 1),
offset: const Offset(0, 1),
spreadRadius: 0,
),
],
@@ -429,7 +499,9 @@ class _LoginScreenState extends FirkaState<LoginScreen> {
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<LoginScreen> {
}
}
//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<Offset> _positions = [];
List<Offset> _velocities = [];
Offset _tilt = Offset.zero;
Offset? _baseline;
StreamSubscription<AccelerometerEvent>? _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<Rect> {
final MediaQueryData _mediaQuery;
ImageClipper(this._mediaQuery);
@override
@@ -476,7 +918,5 @@ class ImageClipper extends CustomClipper<Rect> {
}
@override
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) {
return false;
}
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) => false;
}

View File

@@ -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