Start writing the web app
All checks were successful
ci/woodpecker/push/build Pipeline was successful

Signed-off-by: Nikolai Rodionov <allanger@posteo.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-05-19 13:37:41 +02:00
parent 0c5b657a2f
commit 09df205fdb
76 changed files with 1961 additions and 117 deletions

View File

@@ -0,0 +1,90 @@
import 'dart:developer';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/core/api/v1/public_accounts.dart';
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
import 'package:softplayer_web/features/authorization/application/sign_in_data.dart';
import 'package:softplayer_web/features/authorization/application/sign_up_data.dart';
class AuthState {
final AuthMode mode;
final bool isAuthorized;
const AuthState({this.mode = AuthMode.login, this.isAuthorized = false});
AuthState copyWith({AuthMode? mode, String? status, bool? isAuthorized}) {
return AuthState(
mode: mode ?? this.mode,
isAuthorized: isAuthorized ?? this.isAuthorized,
);
}
}
enum AuthMode { login, signup }
final authorizationControllerProvider =
AsyncNotifierProvider<AuthorizationController, AuthState>(
AuthorizationController.new,
);
class AuthorizationController extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// Use is considered authorized if tokens are set in the memory.
// In case tokens are not valid, it will be discovered by the first
// api call.
final tokenState = await ref.watch(tokensControllerProvider.future);
if (tokenState.getAccessToken().isEmpty &&
tokenState.getRefreshToken().isNotEmpty) {
final tokenCtrl = ref.read(tokensControllerProvider.notifier);
await tokenCtrl.checkTokens();
}
final isAuthorized =
tokenState.getAccessToken().isNotEmpty &&
tokenState.getRefreshToken().isNotEmpty;
return AuthState(isAuthorized: isAuthorized);
}
AuthMode authMode = AuthMode.login;
void toggleAuthMode() {
state = AsyncData(
state.value!.copyWith(
mode: state.value!.mode == AuthMode.login
? AuthMode.signup
: AuthMode.login,
),
);
}
Future<void> signin(SignInData form) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final accountsGrpc = ref.read(publicAccountsGrpcProvider);
final tokenCtrl = ref.read(tokensControllerProvider.notifier);
final response = await accountsGrpc.signIn(form.toProto());
await tokenCtrl.writeTokenPair(Tokens.fromProto(response.tokenPair));
return state.value!.copyWith(isAuthorized: true);
});
}
Future<void> signup(SignUpData form) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final accountsGrpc = ref.read(publicAccountsGrpcProvider);
final tokenCtrl = ref.read(tokensControllerProvider.notifier);
final response = await accountsGrpc.signUp(form.toProto());
await tokenCtrl.writeTokenPair(Tokens.fromProto(response.tokenPair));
return state.value!.copyWith(isAuthorized: true);
});
}
Future<void> logout() async {
state = await AsyncValue.guard(() async {
return state.value!.copyWith(isAuthorized: false);
});
}
}

View File

@@ -0,0 +1,12 @@
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
class SignInData {
final String email;
final String password;
const SignInData({required this.email, required this.password});
SignInRequest toProto() {
return SignInRequest(email: email, password: password);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
class SignUpData {
final String email;
final String password;
final String name;
final String surname;
const SignUpData({
required this.email,
required this.password,
required this.name,
required this.surname,
});
SignUpRequest toProto() {
return SignUpRequest(
email: email,
password: password,
personalData: PersonalData(name: name, surname: surname),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
class AuthDecoration extends StatelessWidget {
final double minSquareSize;
final double spacing;
final Color color;
const AuthDecoration({
super.key,
this.minSquareSize = 40,
this.spacing = 24, // ✅ default 24px gap
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
// Step 1: decide number of columns (max 4)
int columns = 4;
double calcSize(int cols) {
final totalSpacing = spacing * (cols - 1);
return (width - totalSpacing) / cols;
}
while (columns > 1 && calcSize(columns) < minSquareSize) {
columns--;
}
final squareSize = calcSize(columns);
// Step 2: compute how many rows are needed to fill screen height
final height = constraints.maxHeight;
final rows = (height / (squareSize + spacing)).ceil();
return Column(
children: List.generate(rows, (row) {
return Padding(
padding: EdgeInsets.all(spacing),
child: Row(
children: List.generate(columns, (col) {
return Padding(
padding: EdgeInsets.only(
right: col == columns - 1 ? 0 : spacing,
),
child: SizedBox(
width: squareSize,
height: squareSize,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6),
),
),
),
);
}),
),
);
}),
);
},
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
import 'package:softplayer_web/features/authorization/presentation/decoration.dart';
import 'package:softplayer_web/features/authorization/presentation/login_form.dart';
import 'package:softplayer_web/features/authorization/presentation/register_form.dart';
class AuthorizationPage extends ConsumerStatefulWidget {
const AuthorizationPage({super.key});
@override
ConsumerState<AuthorizationPage> createState() => _AuthorizationPage();
}
class _AuthorizationPage extends ConsumerState<AuthorizationPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(authorizationControllerProvider);
return state.when(
data: (value) {
return LayoutBuilder(
builder: (context, constraints) {
final showDecoration = constraints.maxWidth > 1000;
return Scaffold(
body: Row(
children: [
Expanded(
flex: showDecoration ? 7 : 1,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (value.mode == AuthMode.login)
LoginForm()
else
RegisterForm(),
],
),
),
),
// ✅ only show decoration if width > 400
if (showDecoration)
Expanded(flex: 3, child: AuthDecoration()),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:softplayer_web/shared/ui/colors/light_mode.dart';
class AuthDecoration extends StatelessWidget {
final double spacing;
final Color color;
const AuthDecoration({
super.key,
this.spacing = 24,
this.color = Colors.blue,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Container(
color: LightModeColors.backgroundElevated,
child: FractionallySizedBox(
widthFactor: 0.3,
alignment: Alignment.centerLeft,
child: CustomPaint(
painter: _GridPainter(spacing: spacing, color: color),
child: const SizedBox.expand(),
),
),
);
},
);
}
}
// todo: Some AI generated shite, that doesn't work
class _GridPainter extends CustomPainter {
final double spacing;
final Color color;
_GridPainter({required this.spacing, required this.color});
static const double maxSquare = 85;
static const double minSquare = 48; // 2x gap
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
// ✅ outer padding included in layout math
final innerWidth = size.width - spacing * 2;
final innerHeight = size.height - spacing * 2;
int columns = 10;
double calcSize(int cols) {
return (innerWidth - (cols - 1) * spacing) / cols;
}
// choose columns safely
while (columns > 1 && calcSize(columns) < minSquare) {
columns--;
}
double square = calcSize(columns);
square = math.min(square, maxSquare);
final rows = (innerHeight / (square + spacing)).ceil();
for (int row = 0; row < rows; row++) {
final y = spacing + row * (square + spacing);
for (int col = 0; col < columns; col++) {
final x = spacing + col * (square + spacing);
final rect = Rect.fromLTWH(x, y, square, square);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(6)),
paint,
);
}
}
}
@override
bool shouldRepaint(covariant _GridPainter oldDelegate) {
return oldDelegate.spacing != spacing || oldDelegate.color != color;
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
import 'package:softplayer_web/features/authorization/application/sign_in_data.dart';
class LoginForm extends ConsumerStatefulWidget {
const LoginForm({super.key});
@override
ConsumerState<LoginForm> createState() => _LoginForm();
}
class _LoginForm extends ConsumerState<LoginForm> {
final _formKey = GlobalKey<FormState>();
final TextEditingController emailCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
void _submitForm() {
if (_formKey.currentState!.validate()) {
// If valid, you can use the values
final email = emailCtrl.text;
final password = passwordCtrl.text;
final form = SignInData(email: email, password: password);
ref.read(authorizationControllerProvider.notifier).signin(form);
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(authorizationControllerProvider);
final controller = ref.read(authorizationControllerProvider.notifier);
return SizedBox(
width: 400,
child: Form(
key: _formKey,
child: Column(
children: [
Container(
alignment: Alignment.topLeft,
child: SelectableText(
"Welcome back!",
style: Theme.of(context).textTheme.headlineLarge,
),
),
SizedBox(height: 12),
Container(
alignment: Alignment.topLeft,
child: Row(
children: [
Text(
"Don't have an account yet? ",
style: Theme.of(context).textTheme.bodyMedium,
),
TextButton(
onPressed: controller.toggleAuthMode,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
"Sign up now",
style: TextStyle(decoration: TextDecoration.underline),
),
),
],
),
),
SizedBox(height: 36),
TextFormField(
onFieldSubmitted: (_) => _submitForm(),
controller: emailCtrl,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Enter a valid email address';
}
return null;
},
decoration: InputDecoration(hintText: "Email address"),
),
SizedBox(height: 16),
TextFormField(
onFieldSubmitted: (_) => _submitForm(),
controller: passwordCtrl,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 12) {
return 'Password must be at least 12 characters long';
}
final hasNumber = RegExp(r'\d');
final hasSpecialChar = RegExp(
r'[!@#$%^&*(),.?":{}|<>_\-\\[\]\\/+=~`]',
);
if (!hasNumber.hasMatch(value)) {
return 'Password must contain at least one number';
}
if (!hasSpecialChar.hasMatch(value)) {
return 'Password must contain at least one special character';
}
return null;
},
obscureText: true,
decoration: InputDecoration(hintText: "Password"),
),
SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: Text("Forgot password?", textAlign: TextAlign.left),
),
ElevatedButton(
onPressed: _submitForm,
child: const Text('Log in'),
// ) {
// if (_formKey.currentState!.validate()) {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text('Processing Data')),
// );
// }
// _
// },
),
state.when(
loading: () => const CircularProgressIndicator(),
data: (_) => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
import 'package:softplayer_web/features/authorization/application/sign_up_data.dart';
class RegisterForm extends ConsumerStatefulWidget {
const RegisterForm({super.key});
@override
ConsumerState<RegisterForm> createState() => _RegisterForm();
}
class _RegisterForm extends ConsumerState<RegisterForm> {
final emailCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final surnameCtrl = TextEditingController();
final passwordCtrl = TextEditingController();
final repeatPasswordCtrl = TextEditingController();
@override
Widget build(BuildContext context) {
final controller = ref.read(authorizationControllerProvider.notifier);
return SizedBox(
width: 400,
child: Column(
children: [
Container(
alignment: Alignment.topLeft,
child: SelectableText(
"Welcome!",
style: Theme.of(context).textTheme.headlineLarge,
),
),
SizedBox(height: 12),
Container(
alignment: Alignment.topLeft,
child: Row(
children: [
Text(
"Already have an account? ",
style: Theme.of(context).textTheme.bodyMedium,
),
TextButton(
onPressed: controller.toggleAuthMode,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
"Sign in",
style: TextStyle(
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
),
],
),
),
SizedBox(height: 36),
TextField(
controller: nameCtrl,
decoration: InputDecoration(hintText: "Name"),
),
SizedBox(height: 16),
TextField(
controller: surnameCtrl,
decoration: InputDecoration(hintText: "Surname"),
),
SizedBox(height: 16),
TextField(
controller: emailCtrl,
decoration: InputDecoration(hintText: "Email address"),
),
SizedBox(height: 16),
TextField(
controller: passwordCtrl,
obscureText: true,
decoration: InputDecoration(hintText: "Password"),
),
SizedBox(height: 16),
TextField(
controller: repeatPasswordCtrl,
obscureText: true,
decoration: InputDecoration(hintText: "Repeat the password"),
),
TextButton(
onPressed: () {
final form = SignUpData(
email: emailCtrl.text,
password: passwordCtrl.text,
name: nameCtrl.text,
surname: surnameCtrl.text,
);
ref.read(authorizationControllerProvider.notifier).signup(form);
},
child: const Text('Sign up'),
),
],
),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:softplayer_dart_proto/src/accounts/v1/accounts_v1.pbgrpc.dart';
import 'package:softplayer_web/features/authorization/data/public_accounts_grpc_repository.dart';
class DashboardState {
final bool authorized;
const DashboardState({this.authorized = false});
DashboardState copyWith({bool? authorized}) {
return DashboardState(authorized: authorized ?? this.authorized);
}
}
final dashboardControllerProvider =
AsyncNotifierProvider<DashboardController, DashboardState>(
DashboardController.new,
);
class DashboardController extends AsyncNotifier<DashboardState> {
static const _storage = FlutterSecureStorage();
@override
Future<DashboardState> build() async {
final accessToken = await _storage.read(key: "x-access-token");
if (accessToken == null || accessToken.isEmpty) {
return const DashboardState(authorized: false);
}
return const DashboardState(authorized: true);
}
}

View File

@@ -0,0 +1,31 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/features/test/application/test_controller.dart';
class DashboardPage extends ConsumerStatefulWidget {
const DashboardPage({super.key});
@override
ConsumerState<DashboardPage> createState() => _DashboardPage();
}
class _DashboardPage extends ConsumerState<DashboardPage> {
bool debug = false;
@override
Widget build(BuildContext context) {
final ctrl = ref.read(pingControllerProvider.notifier);
log("test");
return Container(
color: Colors.black87,
child: ElevatedButton(
onPressed: () {
ctrl.ping();
},
child: Text("pong"),
),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:softplayer_web/core/api/v1/test.dart';
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
final pingControllerProvider = AsyncNotifierProvider<PingController, String>(
PingController.new,
);
class PingController extends AsyncNotifier<String> {
@override
Future<String> build() async {
return 'Idle';
}
Future<void> ping() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final testGrpc = ref.read(testGrpcProvider);
final tokenCtrl = ref.read(tokensControllerProvider.notifier);
await tokenCtrl.checkTokens();
await testGrpc.pong();
return 'Ping successful';
});
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../application/test_controller.dart';
class PingPage extends ConsumerWidget {
const PingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(pingControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('gRPC Ping')),
body: Center(
child: state.when(
data: (value) => Text(value),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(pingControllerProvider.notifier).ping();
},
child: const Icon(Icons.send),
),
);
}
}