Start writing the web app
All checks were successful
ci/woodpecker/push/build Pipeline was successful
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:
@@ -1,45 +0,0 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/src/test/v1/test_v1.pb.dart';
|
||||
import 'package:softplayer_dart_proto/src/test/v1/test_v1.pbgrpc.dart';
|
||||
|
||||
class TestAuthGrpc {
|
||||
final GrpcWebClientChannel channel;
|
||||
late TestServiceClient serviceStub;
|
||||
TestAuthGrpc({required this.channel});
|
||||
|
||||
void init() {
|
||||
serviceStub = TestServiceClient(channel);
|
||||
}
|
||||
|
||||
Future<void> pong() async {
|
||||
final request = PongRequest();
|
||||
try {
|
||||
final response = await serviceStub.pong(request);
|
||||
log(response.toString());
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestNoAuthGrpc {
|
||||
final GrpcWebClientChannel channel;
|
||||
late PublicTestServiceClient serviceStub;
|
||||
TestNoAuthGrpc({required this.channel});
|
||||
|
||||
void init() {
|
||||
serviceStub = PublicTestServiceClient(channel);
|
||||
}
|
||||
|
||||
Future<void> ping() async {
|
||||
final request = PingRequest();
|
||||
try {
|
||||
final response = await serviceStub.ping(request);
|
||||
log(response.toString());
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
lib/core/api/v1/accounts.dart
Normal file
14
lib/core/api/v1/accounts.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final accountsGrpcProvider = Provider<AccountsGrpcRepository>((ref) {
|
||||
return AccountsGrpcRepository(ref.watch(accountsServiceClientProvider));
|
||||
});
|
||||
|
||||
class AccountsGrpcRepository {
|
||||
AccountsGrpcRepository(this._client);
|
||||
final AccountsServiceClient _client;
|
||||
}
|
||||
35
lib/core/api/v1/public_accounts.dart
Normal file
35
lib/core/api/v1/public_accounts.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final publicAccountsGrpcProvider = Provider<PublicAccountsGrpcRepository>((
|
||||
ref,
|
||||
) {
|
||||
return PublicAccountsGrpcRepository(
|
||||
ref.watch(publicAccountsServiceClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class PublicAccountsGrpcRepository {
|
||||
PublicAccountsGrpcRepository(this._client);
|
||||
final PublicAccountsServiceClient _client;
|
||||
|
||||
ResponseFuture<SignInResponse> signIn(SignInRequest req) {
|
||||
try {
|
||||
final response = _client.signIn(req);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
ResponseFuture<SignUpResponse> signUp(SignUpRequest req) {
|
||||
try {
|
||||
final response = _client.signUp(req);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
lib/core/api/v1/refresh_session.dart
Normal file
28
lib/core/api/v1/refresh_session.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final refreshSessionGrpcProvider = Provider<RefreshSessionGrpcRepository>((
|
||||
ref,
|
||||
) {
|
||||
return RefreshSessionGrpcRepository(
|
||||
ref.watch(refreshSessionServiceClientProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class RefreshSessionGrpcRepository {
|
||||
final RefreshSessionServiceClient _client;
|
||||
RefreshSessionGrpcRepository(this._client);
|
||||
|
||||
ResponseFuture<RefreshSessionResponse> refreshSession(String refreshToken) {
|
||||
try {
|
||||
final response = _client.refreshSession(
|
||||
RefreshSessionRequest(refreshToken: refreshToken),
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/core/api/v1/test.dart
Normal file
22
lib/core/api/v1/test.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
|
||||
final testGrpcProvider = Provider<TestGrpcRepository>((ref) {
|
||||
return TestGrpcRepository(ref.watch(testServiceClientProvider));
|
||||
});
|
||||
|
||||
class TestGrpcRepository {
|
||||
TestGrpcRepository(this._client);
|
||||
final TestServiceClient _client;
|
||||
|
||||
ResponseFuture<PongResponse> pong() {
|
||||
try {
|
||||
final response = _client.pong(PongRequest());
|
||||
return response;
|
||||
} catch (error) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
lib/core/grpc/grpc_auth_interceptor.dart
Normal file
71
lib/core/grpc/grpc_auth_interceptor.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:grpc/grpc.dart';
|
||||
|
||||
class AuthInterceptor extends ClientInterceptor {
|
||||
final String Function() getAccessToken;
|
||||
final String Function() getRefreshToken;
|
||||
|
||||
AuthInterceptor({
|
||||
required this.getAccessToken,
|
||||
required this.getRefreshToken,
|
||||
});
|
||||
|
||||
bool _isPublicEndpoint(String path) {
|
||||
return path.contains('/Public');
|
||||
}
|
||||
|
||||
bool _isRefreshEndpoint(String path) {
|
||||
return path.contains('/RefreshToken');
|
||||
}
|
||||
|
||||
Map<String, String> _buildMetadata(String path) {
|
||||
final metadata = <String, String>{};
|
||||
|
||||
// Public endpoints → no token
|
||||
if (_isPublicEndpoint(path)) {
|
||||
log("Public endpoint, no tokens needed");
|
||||
return metadata;
|
||||
}
|
||||
|
||||
String? token;
|
||||
|
||||
// Refresh endpoint → use refresh token
|
||||
if (_isRefreshEndpoint(path)) {
|
||||
log("Adding a refresh token to the metadata");
|
||||
token = getRefreshToken();
|
||||
} else {
|
||||
log("Adding an acces token to the metadata");
|
||||
token = getAccessToken();
|
||||
}
|
||||
|
||||
if (token.isNotEmpty) {
|
||||
metadata['authorization'] = 'Bearer $token';
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@override
|
||||
ResponseStream<R> interceptStreaming<Q, R>(
|
||||
ClientMethod<Q, R> method,
|
||||
Stream<Q> requests,
|
||||
CallOptions options,
|
||||
ClientStreamingInvoker<Q, R> invoker,
|
||||
) {
|
||||
// TODO: implement interceptStreaming
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
ResponseFuture<R> interceptUnary<Q, R>(
|
||||
ClientMethod<Q, R> method,
|
||||
Q request,
|
||||
CallOptions options,
|
||||
ClientUnaryInvoker<Q, R> invoker,
|
||||
) {
|
||||
final modifiedOptions = options.mergedWith(
|
||||
CallOptions(metadata: _buildMetadata(method.path)),
|
||||
);
|
||||
return super.interceptUnary(method, request, modifiedOptions, invoker);
|
||||
}
|
||||
}
|
||||
16
lib/core/grpc/grpc_channel_provider.dart
Normal file
16
lib/core/grpc/grpc_channel_provider.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
|
||||
final grpcChannelProvider = Provider<GrpcWebClientChannel>((ref) {
|
||||
String backendURL = dotenv.env['SOFTPLAYER_BACKEND_URL']!;
|
||||
final GrpcWebClientChannel channel = GrpcWebClientChannel.xhr(
|
||||
Uri.parse(backendURL),
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
await channel.shutdown();
|
||||
});
|
||||
|
||||
return channel;
|
||||
});
|
||||
54
lib/core/grpc/grpc_client.dart
Normal file
54
lib/core/grpc/grpc_client.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_dart_proto/test/v1/test_v1.pbgrpc.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_auth_interceptor.dart';
|
||||
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
|
||||
|
||||
import 'grpc_channel_provider.dart';
|
||||
|
||||
final testServiceClientProvider = Provider<TestServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
final tokenState = ref.read(tokensControllerProvider).value;
|
||||
if (tokenState == null) {
|
||||
throw Exception("Token state is not initialized");
|
||||
}
|
||||
|
||||
return TestServiceClient(
|
||||
channel,
|
||||
interceptors: [
|
||||
AuthInterceptor(
|
||||
getAccessToken: tokenState.getAccessToken,
|
||||
getRefreshToken: tokenState.getRefreshToken,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
final accountsServiceClientProvider = Provider<AccountsServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
final tokenState = ref.read(tokensControllerProvider).value;
|
||||
if (tokenState == null) {
|
||||
throw Exception("Token state is not initialized");
|
||||
}
|
||||
return AccountsServiceClient(
|
||||
channel,
|
||||
interceptors: [
|
||||
AuthInterceptor(
|
||||
getAccessToken: tokenState.getAccessToken,
|
||||
getRefreshToken: tokenState.getRefreshToken,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
final publicAccountsServiceClientProvider =
|
||||
Provider<PublicAccountsServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
return PublicAccountsServiceClient(channel);
|
||||
});
|
||||
|
||||
final refreshSessionServiceClientProvider =
|
||||
Provider<RefreshSessionServiceClient>((ref) {
|
||||
final channel = ref.watch(grpcChannelProvider);
|
||||
return RefreshSessionServiceClient(channel);
|
||||
});
|
||||
30
lib/core/router/app_router.dart
Normal file
30
lib/core/router/app_router.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:softplayer_web/core/tokens/application/tokens_application.dart';
|
||||
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
|
||||
import 'package:softplayer_web/features/authorization/presentation/authorization_page.dart';
|
||||
import 'package:softplayer_web/features/dashboard/presentation/dashboard_page.dart';
|
||||
|
||||
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authorizationControllerProvider);
|
||||
final _tokenCtrl = ref.watch(tokensControllerProvider);
|
||||
|
||||
// If not authorized, redirect to the auth page, otherwise dashboard
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
redirect: (context, state) {
|
||||
final isAuthorized = authState.requireValue.isAuthorized;
|
||||
if (!isAuthorized) {
|
||||
return "/auth";
|
||||
}
|
||||
return "/";
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const DashboardPage()),
|
||||
GoRoute(
|
||||
path: '/auth',
|
||||
builder: (context, state) => const AuthorizationPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
167
lib/core/tokens/application/tokens_application.dart
Normal file
167
lib/core/tokens/application/tokens_application.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import 'package:softplayer_dart_proto/accounts/v1/accounts_v1.pb.dart';
|
||||
import 'package:softplayer_web/core/api/v1/accounts.dart';
|
||||
import 'package:softplayer_web/core/api/v1/refresh_session.dart';
|
||||
import 'package:softplayer_web/core/grpc/grpc_client.dart';
|
||||
import 'package:softplayer_web/core/tokens/data/token_storage_repository.dart';
|
||||
import 'package:softplayer_web/features/authorization/application/authorization_application.dart';
|
||||
|
||||
class TokenState {
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
const TokenState({this.accessToken, this.refreshToken});
|
||||
|
||||
TokenState copyWith({String? accessToken, String? refreshToken}) {
|
||||
return TokenState(
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
// Get an access token from the state
|
||||
String getAccessToken() {
|
||||
return accessToken ?? "";
|
||||
}
|
||||
|
||||
// Get a refresh token from the state
|
||||
String getRefreshToken() {
|
||||
return refreshToken ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const accessTokenHeader = "x-access-token";
|
||||
const refreshTokenHeader = "x-refresh-token";
|
||||
|
||||
class Tokens {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
const Tokens({required this.accessToken, required this.refreshToken});
|
||||
|
||||
factory Tokens.fromProto(TokenPair pair) {
|
||||
return Tokens(
|
||||
accessToken: pair.accessToken,
|
||||
refreshToken: pair.refreshToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final tokensControllerProvider =
|
||||
AsyncNotifierProvider<TokensController, TokenState>(TokensController.new);
|
||||
|
||||
class TokensController extends AsyncNotifier<TokenState> {
|
||||
@override
|
||||
Future<TokenState> build() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
final accessToken = await tokenRepo.getAccessToken();
|
||||
final refreshToken = await tokenRepo.getRefreshToken();
|
||||
|
||||
// If refresh token is not valid, we just drop the session,
|
||||
// even if there is an active access token, and return
|
||||
// an empty state
|
||||
if (!verifyJWT(refreshToken)) {
|
||||
log("Refresh token is not valid, logging out");
|
||||
await tokenRepo.clearStorage();
|
||||
return TokenState();
|
||||
}
|
||||
|
||||
// If access token is not valid, refresh it using the refresh token
|
||||
if (verifyJWT(accessToken)) {
|
||||
log("Access token is valid");
|
||||
return TokenState(refreshToken: refreshToken, accessToken: accessToken);
|
||||
}
|
||||
log("Only refresh token is valid");
|
||||
return TokenState(refreshToken: refreshToken);
|
||||
}
|
||||
|
||||
Future<void> resetTokens() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
await tokenRepo.clearStorage();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return TokenState();
|
||||
});
|
||||
}
|
||||
|
||||
// Store an access token to the storage and save it to the state
|
||||
Future<void> writeAccessToken(String token) async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
await tokenRepo.storeAccessToken(token);
|
||||
state = await AsyncValue.guard(() async {
|
||||
return state.value!.copyWith(accessToken: token);
|
||||
});
|
||||
}
|
||||
|
||||
// Store a pair of tokens to the storage and refresh the state
|
||||
Future<void> writeTokenPair(Tokens pair) async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
log("Writing tokens");
|
||||
await tokenRepo.storeRefreshToken(pair.refreshToken);
|
||||
await tokenRepo.storeAccessToken(pair.accessToken);
|
||||
state = await AsyncValue.guard(() async {
|
||||
return state.value!.copyWith(
|
||||
accessToken: pair.accessToken,
|
||||
refreshToken: pair.refreshToken,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> checkTokens() async {
|
||||
final currentState = state.value;
|
||||
if (currentState == null ||
|
||||
currentState.accessToken == null ||
|
||||
currentState.refreshToken == null) {
|
||||
log("Trying to get tokens from the storage");
|
||||
state = await AsyncValue.guard(() async {
|
||||
final tokenRepo = ref.read(tokenStorageRepositoryProvider);
|
||||
return TokenState(
|
||||
accessToken: await tokenRepo.getAccessToken(),
|
||||
refreshToken: await tokenRepo.getRefreshToken(),
|
||||
);
|
||||
});
|
||||
}
|
||||
log("checking tokens");
|
||||
|
||||
final inMemAccessToken = state.value!.getAccessToken();
|
||||
// If we have a valid token in memory, nothing must be done
|
||||
// TODO: Store the expiration in memory, to make it cheaper to check
|
||||
if (verifyJWT(inMemAccessToken)) {
|
||||
return;
|
||||
} else {
|
||||
log("Access it not valid");
|
||||
final inMemRefreshToken = state.value!.getRefreshToken();
|
||||
if (verifyJWT(inMemRefreshToken)) {
|
||||
log("Trying to refresh");
|
||||
try {
|
||||
final refreshSessionGrpc = ref.read(refreshSessionGrpcProvider);
|
||||
log("Trying to refresh");
|
||||
final response = await refreshSessionGrpc.refreshSession(
|
||||
inMemRefreshToken,
|
||||
);
|
||||
await writeTokenPair(Tokens.fromProto(response.tokenPair));
|
||||
return;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Refrsh it not valid");
|
||||
await resetTokens();
|
||||
ref.read(authorizationControllerProvider.notifier).logout();
|
||||
}
|
||||
|
||||
bool verifyJWT(String? token) {
|
||||
if (token == null || token.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
bool isExpired = JwtDecoder.isExpired(token);
|
||||
return !isExpired;
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/core/tokens/data/token_storage_repository.dart
Normal file
42
lib/core/tokens/data/token_storage_repository.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const accessTokenKey = "access_token";
|
||||
const refreshTokenKey = "refresh_token";
|
||||
|
||||
final tokenStorageRepositoryProvider = Provider<TokenStorageRepository>((ref) {
|
||||
return TokenStorageRepository();
|
||||
});
|
||||
|
||||
// Secure storage backend for storing tokens in the browser
|
||||
class TokenStorageRepository {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
TokenStorageRepository();
|
||||
|
||||
// Store the refresh token in the storage
|
||||
Future<void> storeRefreshToken(String token) async {
|
||||
await _storage.write(key: refreshTokenKey, value: token);
|
||||
}
|
||||
|
||||
// Store the access token in the storage
|
||||
Future<void> storeAccessToken(String token) async {
|
||||
await _storage.write(key: accessTokenKey, value: token);
|
||||
}
|
||||
|
||||
// Get the acccess token from the storage
|
||||
Future<String?> getAccessToken() async {
|
||||
final token = await _storage.read(key: accessTokenKey);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Get the refresh token from the storage
|
||||
Future<String?> getRefreshToken() async {
|
||||
final token = await _storage.read(key: refreshTokenKey);
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> clearStorage() async {
|
||||
await _storage.delete(key: refreshTokenKey);
|
||||
await _storage.delete(key: accessTokenKey);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
12
lib/features/authorization/application/sign_in_data.dart
Normal file
12
lib/features/authorization/application/sign_in_data.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
23
lib/features/authorization/application/sign_up_data.dart
Normal file
23
lib/features/authorization/application/sign_up_data.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/features/authorization/presentation/.conform.8331486.decoration.dart
Executable file
68
lib/features/authorization/presentation/.conform.8331486.decoration.dart
Executable 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/features/authorization/presentation/decoration.dart
Normal file
89
lib/features/authorization/presentation/decoration.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
144
lib/features/authorization/presentation/login_form.dart
Normal file
144
lib/features/authorization/presentation/login_form.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/authorization/presentation/register_form.dart
Normal file
105
lib/features/authorization/presentation/register_form.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
31
lib/features/dashboard/presentation/dashboard_page.dart
Normal file
31
lib/features/dashboard/presentation/dashboard_page.dart
Normal 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"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/features/test/application/test_controller.dart
Normal file
28
lib/features/test/application/test_controller.dart
Normal 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';
|
||||
});
|
||||
}
|
||||
}
|
||||
30
lib/features/test/presentation/ping_page.dart
Normal file
30
lib/features/test/presentation/ping_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,28 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:softplayer_web/api/grpc/test_service.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:grpc/grpc_web.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:softplayer_web/shared/ui/theme/app_theme.dart';
|
||||
|
||||
import 'core/router/app_router.dart';
|
||||
|
||||
void main() async {
|
||||
await dotenv.load(fileName: ".env");
|
||||
String backendURL = dotenv.env['SOFTPLAYER_BACKEND_URL']!;
|
||||
GrpcWebClientChannel grpcChannel = GrpcWebClientChannel.xhr(
|
||||
Uri.parse(backendURL),
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
runApp(MyApp(channel: grpcChannel));
|
||||
SemanticsBinding.instance.ensureSemantics();
|
||||
await dotenv.load(fileName: '.env');
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key, required this.channel});
|
||||
final GrpcWebClientChannel channel;
|
||||
class App extends ConsumerWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: true,
|
||||
title: 'Softplayer',
|
||||
home: RootWidget(channel: channel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RootWidget extends StatefulWidget {
|
||||
final GrpcWebClientChannel channel;
|
||||
const RootWidget({super.key, required this.channel});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _StateRootWidget();
|
||||
}
|
||||
|
||||
class _StateRootWidget extends State<RootWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Column(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
TestAuthGrpc grpc = TestAuthGrpc(channel: widget.channel);
|
||||
grpc.init();
|
||||
await grpc.pong();
|
||||
},
|
||||
child: Text("pong"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
TestNoAuthGrpc grpc = TestNoAuthGrpc(channel: widget.channel);
|
||||
grpc.init();
|
||||
await grpc.ping();
|
||||
},
|
||||
child: Text("ping"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(goRouterProvider);
|
||||
return MaterialApp.router(
|
||||
routerConfig: router,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.light,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
8
lib/shared/ui/colors/dark_mode.dart
Normal file
8
lib/shared/ui/colors/dark_mode.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DarkModeColors {
|
||||
static const darkBackground = Color(0xFF121212);
|
||||
static const darkSurface = Color(0xFF1E1E1E);
|
||||
static const darkPrimary = Color(0xFF8B7CFF);
|
||||
static const darkText = Color(0xFFEDEDED);
|
||||
}
|
||||
21
lib/shared/ui/colors/light_mode.dart
Normal file
21
lib/shared/ui/colors/light_mode.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LightModeColors {
|
||||
// Light theme
|
||||
static const backgroundPrimary = Color(0xFFF6F7F9);
|
||||
static const backgroundElevated = Color(0xFFEEF0F3);
|
||||
static const lightSurface = Color(0xFFF5F5F5);
|
||||
static const lightPrimary = Color(0xFF6C5CE7);
|
||||
static const lightText = Color(0xFF1A1A1A);
|
||||
|
||||
// Inputs
|
||||
static const inputBackground = Color(0xFFFFFFFF);
|
||||
static const inputDefaultBorder = Color(0xFFD9D9D9);
|
||||
static const inputFocusedBorder = Color(0xFFEFFF1A);
|
||||
|
||||
// Text
|
||||
static const textPrimary = Color(0xFF14161A);
|
||||
|
||||
// Buttons
|
||||
static const buttonPrimary = Color(0xFFEFFF1A);
|
||||
}
|
||||
109
lib/shared/ui/theme/app_theme.dart
Normal file
109
lib/shared/ui/theme/app_theme.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../colors/dark_mode.dart';
|
||||
import '../colors/light_mode.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData light() {
|
||||
return ThemeData(
|
||||
brightness: Brightness.light,
|
||||
scaffoldBackgroundColor: LightModeColors.backgroundPrimary,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: LightModeColors.lightPrimary,
|
||||
surface: LightModeColors.backgroundPrimary,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 22.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: LightModeColors.textPrimary,
|
||||
fontFamily: "Syne",
|
||||
),
|
||||
|
||||
headlineMedium: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: LightModeColors.textPrimary,
|
||||
),
|
||||
),
|
||||
useMaterial3: true,
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: "Inter",
|
||||
color: LightModeColors.textPrimary,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: "Inter",
|
||||
color: LightModeColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
foregroundColor: LightModeColors.textPrimary,
|
||||
backgroundColor: LightModeColors.buttonPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 0.0,
|
||||
),
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
// isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
|
||||
// Optional hard height limits
|
||||
// constraints: BoxConstraints(minHeight: 48, maxHeight: 40),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide(
|
||||
color: LightModeColors.inputFocusedBorder,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide(
|
||||
color: LightModeColors.inputDefaultBorder,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
fillColor: LightModeColors.inputBackground,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
return ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: DarkModeColors.darkBackground,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: DarkModeColors.darkPrimary,
|
||||
surface: DarkModeColors.darkSurface,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(color: DarkModeColors.darkText),
|
||||
),
|
||||
useMaterial3: true,
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.greenAccent, width: 5.0),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.red, width: 5.0),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user