Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27424ed886 | ||
|
|
d9318053e8 |
No files matched your search
Vendored
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"command": "./node_modules/.bin/astro dev",
|
"command": "npm run dev -- --background",
|
||||||
|
"cwd": "${workspaceFolder}/website",
|
||||||
"name": "Development server",
|
"name": "Development server",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "node-terminal"
|
"type": "node-terminal"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SXCHNGE backend
|
||||||
|
|
||||||
|
Reserved for a small backend for frontend (BFF) using a supported Sendico API. No service, runtime, or dependencies have been initialized yet.
|
||||||
|
|
||||||
|
Planned responsibilities:
|
||||||
|
|
||||||
|
- Consumer authentication and sessions, with a stable mapping to organization-scoped Sendico customer references.
|
||||||
|
- Consumer-authorized access to onboarding, quotes, orders, and transaction history.
|
||||||
|
- Product-specific orchestration and presentation of Sendico KYC, eligibility, and remaining-limit responses.
|
||||||
|
- Server-side integration credentials and request correlation with Sendico.
|
||||||
|
|
||||||
|
Sendico owns payment execution, authoritative KYC evidence and verification records, policy evaluation, and limit reservations/enforcement. Do not copy its existing BFF or import local Sendico implementation modules.
|
||||||
|
|
||||||
|
Before implementation, agree on customer registration and identity mapping, customer KYC operations, eligibility/limits reads, customer-scoped payment access, and retry/idempotency behavior. Validate consumer isolation even when multiple customers use the same sxchnge organization and integration principal.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# SXCHNGE frontend
|
||||||
|
|
||||||
|
Home for the consumer application and its Flutter packages. The existing public Astro website lives in [`../website/`](../website/README.md).
|
||||||
|
|
||||||
|
The first package, [`sxchnge_theme`](packages/sxchnge_theme/README.md), provides installable light/dark Material themes and bundled Onest fonts. Its `example/` folder demonstrates installation and component styling. The consumer application in [`app/`](app/README.md) now includes a responsive login/sign-up preview; backend authentication is not connected.
|
||||||
|
|
||||||
|
The application will provide consumer registration, sign-in, onboarding and verification screens, exchange flows, and transaction history through the sxchnge backend. Eligibility and remaining limits are projections of Sendico's authoritative decisions.
|
||||||
|
|
||||||
|
Flutter is anticipated by the existing website handoff. Optional brand references are in [`../website/design/`](../website/design/README.md) and [`../website/flutter/brand_palette.dart`](../website/flutter/brand_palette.dart); these do not prescribe the application's component styling.
|
||||||
|
|
||||||
|
Define the first customer journey and BFF contract before scaffolding the application. Sendico integration credentials belong in the backend.
|
||||||
|
|
||||||
|
Shared authentication: [`sxchnge_auth`](packages/sxchnge_auth/README.md) provides reusable login/signup providers, service contracts, and dummy implementations for both web and future mobile clients.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# sxchnge_auth
|
||||||
|
|
||||||
|
Shared Flutter authentication providers and service contracts for web and mobile. Private package, version 0.1.0; Flutter 3.47+ / Dart 3.13+.
|
||||||
|
|
||||||
|
## Install and use
|
||||||
|
|
||||||
|
From `frontend/app` (or a sibling `frontend/mobile` app):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
sxchnge_auth:
|
||||||
|
path: ../packages/sxchnge_auth
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
final login = LoginProvider(const DummyLoginService());
|
||||||
|
login.addListener(() {
|
||||||
|
// Render login.state.status, result, or typed error in your own UI.
|
||||||
|
});
|
||||||
|
await login.login(
|
||||||
|
email: 'demo@example.com',
|
||||||
|
password: 'example-password',
|
||||||
|
locale: 'en',
|
||||||
|
);
|
||||||
|
login.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
`SignupProvider` accepts a `SignupService` and exposes `signup(name:, email:, password:, locale:)`. Both providers extend ChangeNotifier and can be installed with `ChangeNotifierProvider`, ListenableBuilder, or another Flutter state binding. The package itself depends only on Flutter foundation; it contains no widgets, routes, localization, web-only code, network client, or storage.
|
||||||
|
|
||||||
|
## Lifecycle and services
|
||||||
|
|
||||||
|
Providers expose immutable `AuthState`: idle, loading, success, or error. They trim email/name, preserve the password verbatim, pass the locale to services, suppress duplicate submissions while loading, and clear old errors before retrying. Reset invalidates older responses and allows a new request; disposal ignores all later completions. Reset does not cancel remote work. Do not treat a provider reset as undoing a production signup.
|
||||||
|
|
||||||
|
Services return `AuthResult` or throw `AuthException` with a typed `AuthError`. Unexpected exceptions are reduced to `unexpected`; raw errors and credentials are not retained in provider state. Credentials exist only as ephemeral request input. Localize error codes in each consuming application.
|
||||||
|
|
||||||
|
`DummyLoginService` and `DummySignupService` default to 600ms latency and simulated success. They do not validate credentials against an account registry, create accounts, issue tokens, open sessions, send requests, or persist data. Configure errors explicitly:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final service = DummyLoginService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
failure: AuthError.invalidCredentials,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported failure codes: `invalidCredentials`, `emailInUse`, `unavailable`, and `unexpected`. Inject another implementation for deterministic retry scenarios or production integration. `AuthResult.isSimulated` distinguishes dummy responses; a successful operation alone must never be treated as an authorization decision. The package does not implement authenticated application/session state yet.
|
||||||
|
|
||||||
|
## Sendico reuse
|
||||||
|
|
||||||
|
Adapted the ChangeNotifier lifecycle, service separation, locale-carrying request, and typed-outcome patterns from Sendico's `pshared/lib/provider/account.dart`, `models/auth/login_outcome.dart`, and `service/authorization/client.dart`. These were patterns rather than directly portable implementations: the originals depend on Sendico account/organization models, device IDs, static API services, token storage, and verification flows. Those dependencies are deliberately absent here. The B2B organization signup contract is not copied into the consumer product.
|
||||||
|
|
||||||
|
When a supported sxchnge BFF contract exists, implement transport DTOs and mappers behind the service interfaces, returning domain results. No dummy DTO/mapping layer is needed without serialized transport. Keep web cookie and mobile credential handling in production platform adapters, not the providers.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`flutter analyze` and `flutter test` from this directory; `make test-frontend` from the repository root also runs all consuming app and theme tests. Tests cover duplicate submission, request forwarding, typed/unexpected failures, retry, reset races, disposal, and both dummy implementations.
|
||||||
|
|
||||||
|
## Email verification
|
||||||
|
|
||||||
|
`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state.
|
||||||
|
|
||||||
|
`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||||
|
class LoginRequest {
|
||||||
|
const LoginRequest({
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
required this.locale,
|
||||||
|
});
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
final String locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignupRequest {
|
||||||
|
const SignupRequest({
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.password,
|
||||||
|
required this.locale,
|
||||||
|
});
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String password;
|
||||||
|
final String locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An operation result, not proof of a session or an authorization decision.
|
||||||
|
class AuthResult {
|
||||||
|
const AuthResult({required this.userId, required this.isSimulated});
|
||||||
|
final String userId;
|
||||||
|
final bool isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected }
|
||||||
|
|
||||||
|
class AuthException implements Exception {
|
||||||
|
const AuthException(this.code);
|
||||||
|
final AuthError code;
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthException(${code.name})';
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthStatus { idle, loading, success, error }
|
||||||
|
|
||||||
|
class AuthState {
|
||||||
|
const AuthState._(this.status, {this.result, this.error});
|
||||||
|
const AuthState.idle() : this._(AuthStatus.idle);
|
||||||
|
const AuthState.loading() : this._(AuthStatus.loading);
|
||||||
|
const AuthState.success(AuthResult result)
|
||||||
|
: this._(AuthStatus.success, result: result);
|
||||||
|
const AuthState.failure(AuthError error)
|
||||||
|
: this._(AuthStatus.error, error: error);
|
||||||
|
final AuthStatus status;
|
||||||
|
final AuthResult? result;
|
||||||
|
final AuthError? error;
|
||||||
|
bool get isLoading => status == AuthStatus.loading;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
enum VerificationStatus {
|
||||||
|
idle,
|
||||||
|
sending,
|
||||||
|
sent,
|
||||||
|
checking,
|
||||||
|
verified,
|
||||||
|
expired,
|
||||||
|
invalid,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationError { expired, invalid, internal }
|
||||||
|
|
||||||
|
class VerificationException implements Exception {
|
||||||
|
const VerificationException(this.code);
|
||||||
|
final VerificationError code;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerificationResult {
|
||||||
|
const VerificationResult({required this.isSimulated});
|
||||||
|
final bool isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerificationState {
|
||||||
|
const VerificationState(this.status, {this.isSimulated = false});
|
||||||
|
final VerificationStatus status;
|
||||||
|
final bool isSimulated;
|
||||||
|
bool get isLoading =>
|
||||||
|
status == VerificationStatus.sending ||
|
||||||
|
status == VerificationStatus.checking;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/auth.dart';
|
||||||
|
|
||||||
|
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
|
||||||
|
/// Reset invalidates pending results; it does not cancel service-side work.
|
||||||
|
abstract class AuthProvider extends ChangeNotifier {
|
||||||
|
AuthState _state = const AuthState.idle();
|
||||||
|
int _generation = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
AuthState get state => _state;
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Future<void> run(Future<AuthResult> Function() operation) async {
|
||||||
|
if (_disposed || _state.isLoading) return;
|
||||||
|
final generation = ++_generation;
|
||||||
|
_state = const AuthState.loading();
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await operation();
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
_state = AuthState.success(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
// Never retain or expose arbitrary exceptions that may contain credentials.
|
||||||
|
_state = AuthState.failure(
|
||||||
|
error is AuthException ? error.code : AuthError.unexpected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!_disposed && generation == _generation) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
if (_disposed) return;
|
||||||
|
++_generation;
|
||||||
|
_state = const AuthState.idle();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
++_generation;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import '../models/auth.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
import 'auth.dart';
|
||||||
|
|
||||||
|
class LoginProvider extends AuthProvider {
|
||||||
|
LoginProvider(this.service);
|
||||||
|
final LoginService service;
|
||||||
|
|
||||||
|
Future<void> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String locale,
|
||||||
|
}) => run(
|
||||||
|
() => service.login(
|
||||||
|
LoginRequest(email: email.trim(), password: password, locale: locale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import '../models/auth.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
import 'auth.dart';
|
||||||
|
|
||||||
|
class SignupProvider extends AuthProvider {
|
||||||
|
SignupProvider(this.service);
|
||||||
|
final SignupService service;
|
||||||
|
|
||||||
|
Future<void> signup({
|
||||||
|
required String name,
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
required String locale,
|
||||||
|
}) => run(
|
||||||
|
() => service.signup(
|
||||||
|
SignupRequest(
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
password: password,
|
||||||
|
locale: locale,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/verification.dart';
|
||||||
|
import '../services/verification.dart';
|
||||||
|
|
||||||
|
class VerificationProvider extends ChangeNotifier {
|
||||||
|
VerificationProvider(this.service);
|
||||||
|
final VerificationService service;
|
||||||
|
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
||||||
|
VerificationState get state => _state;
|
||||||
|
bool _disposed = false;
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
Future<void> send({required String email, required String locale}) => _run(
|
||||||
|
() => service.send(email: email.trim(), locale: locale),
|
||||||
|
VerificationStatus.sending,
|
||||||
|
VerificationStatus.sent,
|
||||||
|
);
|
||||||
|
Future<void> confirm(String token) => _run(
|
||||||
|
() => token.trim().isEmpty
|
||||||
|
? Future.error(const VerificationException(VerificationError.invalid))
|
||||||
|
: service.confirm(token: token),
|
||||||
|
VerificationStatus.checking,
|
||||||
|
VerificationStatus.verified,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _run(
|
||||||
|
Future<VerificationResult> Function() operation,
|
||||||
|
VerificationStatus loading,
|
||||||
|
VerificationStatus success,
|
||||||
|
) async {
|
||||||
|
if (_disposed || _state.isLoading) return;
|
||||||
|
final generation = ++_generation;
|
||||||
|
_state = VerificationState(loading);
|
||||||
|
notifyListeners();
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
try {
|
||||||
|
final result = await operation();
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
_state = VerificationState(success, isSimulated: result.isSimulated);
|
||||||
|
} catch (error) {
|
||||||
|
if (_disposed || generation != _generation) return;
|
||||||
|
final code = error is VerificationException
|
||||||
|
? error.code
|
||||||
|
: VerificationError.internal;
|
||||||
|
// Send failures must not be presented as confirmation-token failures.
|
||||||
|
_state = VerificationState(
|
||||||
|
loading == VerificationStatus.sending
|
||||||
|
? VerificationStatus.error
|
||||||
|
: switch (code) {
|
||||||
|
VerificationError.expired => VerificationStatus.expired,
|
||||||
|
VerificationError.invalid => VerificationStatus.invalid,
|
||||||
|
VerificationError.internal => VerificationStatus.error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
if (_disposed) return;
|
||||||
|
++_generation;
|
||||||
|
_state = const VerificationState(VerificationStatus.idle);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
++_generation;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import '../models/auth.dart';
|
||||||
|
|
||||||
|
/// Production adapters own transport, DTO mapping, and authentication effects.
|
||||||
|
/// Providers and widgets only consume domain results and typed failures.
|
||||||
|
abstract interface class LoginService {
|
||||||
|
Future<AuthResult> login(LoginRequest request);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class SignupService {
|
||||||
|
Future<AuthResult> signup(SignupRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import '../models/auth.dart';
|
||||||
|
import 'contracts.dart';
|
||||||
|
|
||||||
|
/// Deterministic local simulation. No network, storage, credentials or sessions
|
||||||
|
/// are retained. Configure failures explicitly rather than using magic emails.
|
||||||
|
class DummyLoginService implements LoginService {
|
||||||
|
const DummyLoginService({
|
||||||
|
this.delay = const Duration(milliseconds: 600),
|
||||||
|
this.failure,
|
||||||
|
});
|
||||||
|
final Duration delay;
|
||||||
|
final AuthError? failure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResult> login(LoginRequest request) async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
if (failure case final error?) throw AuthException(error);
|
||||||
|
return const AuthResult(userId: 'demo-user', isSimulated: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DummySignupService implements SignupService {
|
||||||
|
const DummySignupService({
|
||||||
|
this.delay = const Duration(milliseconds: 600),
|
||||||
|
this.failure,
|
||||||
|
});
|
||||||
|
final Duration delay;
|
||||||
|
final AuthError? failure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
if (failure case final error?) throw AuthException(error);
|
||||||
|
return const AuthResult(userId: 'demo-user', isSimulated: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import '../models/verification.dart';
|
||||||
|
|
||||||
|
abstract interface class VerificationService {
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
});
|
||||||
|
Future<VerificationResult> confirm({required String token});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit preview tokens only. Never use this implementation in production.
|
||||||
|
class DummyVerificationService implements VerificationService {
|
||||||
|
const DummyVerificationService({
|
||||||
|
this.delay = const Duration(milliseconds: 600),
|
||||||
|
this.sendFailure,
|
||||||
|
});
|
||||||
|
final Duration delay;
|
||||||
|
final VerificationError? sendFailure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
}) async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
if (sendFailure case final error?) throw VerificationException(error);
|
||||||
|
return const VerificationResult(isSimulated: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
switch (token) {
|
||||||
|
case 'demo-valid':
|
||||||
|
return const VerificationResult(isSimulated: true);
|
||||||
|
case 'demo-expired':
|
||||||
|
throw const VerificationException(VerificationError.expired);
|
||||||
|
case 'demo-internal':
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
default:
|
||||||
|
throw const VerificationException(VerificationError.invalid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// Platform-independent authentication state and injectable services.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/models/auth.dart';
|
||||||
|
export 'src/providers/auth.dart';
|
||||||
|
export 'src/providers/login.dart';
|
||||||
|
export 'src/providers/signup.dart';
|
||||||
|
export 'src/services/contracts.dart';
|
||||||
|
export 'src/services/dummy.dart';
|
||||||
|
export 'src/models/verification.dart';
|
||||||
|
export 'src/providers/verification.dart';
|
||||||
|
export 'src/services/verification.dart';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name: sxchnge_auth
|
||||||
|
description: Shared authentication providers and service contracts for SXCHNGE Flutter clients.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
flutter: '>=3.47.0'
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
class ControlledService implements LoginService, SignupService {
|
||||||
|
final calls = <Completer<AuthResult>>[];
|
||||||
|
LoginRequest? loginInput;
|
||||||
|
SignupRequest? signupInput;
|
||||||
|
Future<AuthResult> next() {
|
||||||
|
final call = Completer<AuthResult>();
|
||||||
|
calls.add(call);
|
||||||
|
return call.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResult> login(LoginRequest request) {
|
||||||
|
loginInput = request;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthResult> signup(SignupRequest request) {
|
||||||
|
signupInput = request;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const result = AuthResult(userId: 'demo', isSimulated: true);
|
||||||
|
for (final signup in [false, true]) {
|
||||||
|
group(signup ? 'signup' : 'login', () {
|
||||||
|
late ControlledService service;
|
||||||
|
late AuthProvider provider;
|
||||||
|
Future<void> submit() => signup
|
||||||
|
? (provider as SignupProvider).signup(
|
||||||
|
name: ' Alex ',
|
||||||
|
email: ' a@example.com ',
|
||||||
|
password: ' secret ',
|
||||||
|
locale: 'ru',
|
||||||
|
)
|
||||||
|
: (provider as LoginProvider).login(
|
||||||
|
email: ' a@example.com ',
|
||||||
|
password: ' secret ',
|
||||||
|
locale: 'ru',
|
||||||
|
);
|
||||||
|
setUp(() {
|
||||||
|
service = ControlledService();
|
||||||
|
provider = signup ? SignupProvider(service) : LoginProvider(service);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'loading, duplicate suppression, result and normalized input',
|
||||||
|
() async {
|
||||||
|
final states = <AuthStatus>[];
|
||||||
|
provider.addListener(() => states.add(provider.state.status));
|
||||||
|
final first = submit();
|
||||||
|
await submit();
|
||||||
|
expect(service.calls, hasLength(1));
|
||||||
|
expect(provider.state.isLoading, isTrue);
|
||||||
|
if (signup) {
|
||||||
|
expect(service.signupInput!.name, 'Alex');
|
||||||
|
expect(service.signupInput!.email, 'a@example.com');
|
||||||
|
expect(service.signupInput!.password, ' secret ');
|
||||||
|
expect(service.signupInput!.locale, 'ru');
|
||||||
|
} else {
|
||||||
|
expect(service.loginInput!.email, 'a@example.com');
|
||||||
|
expect(service.loginInput!.password, ' secret ');
|
||||||
|
expect(service.loginInput!.locale, 'ru');
|
||||||
|
}
|
||||||
|
service.calls.single.complete(result);
|
||||||
|
await first;
|
||||||
|
expect(states, [AuthStatus.loading, AuthStatus.success]);
|
||||||
|
expect(provider.state.result, same(result));
|
||||||
|
provider.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('typed failure and retry clear previous error', () async {
|
||||||
|
final first = submit();
|
||||||
|
service.calls.last.completeError(
|
||||||
|
const AuthException(AuthError.unavailable),
|
||||||
|
);
|
||||||
|
await first;
|
||||||
|
expect(provider.state.error, AuthError.unavailable);
|
||||||
|
final retry = submit();
|
||||||
|
expect(provider.state.error, isNull);
|
||||||
|
service.calls.last.complete(result);
|
||||||
|
await retry;
|
||||||
|
expect(provider.state.status, AuthStatus.success);
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
test('unknown exceptions are sanitized', () async {
|
||||||
|
final first = submit();
|
||||||
|
service.calls.last.completeError(StateError('secret password'));
|
||||||
|
await first;
|
||||||
|
expect(provider.state.error, AuthError.unexpected);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
test('reset rejects stale completion during a new operation', () async {
|
||||||
|
final first = submit();
|
||||||
|
provider.reset();
|
||||||
|
expect(provider.state.status, AuthStatus.idle);
|
||||||
|
final second = submit();
|
||||||
|
service.calls.first.complete(result);
|
||||||
|
await first;
|
||||||
|
expect(provider.state.status, AuthStatus.loading);
|
||||||
|
service.calls.last.complete(result);
|
||||||
|
await second;
|
||||||
|
expect(provider.state.status, AuthStatus.success);
|
||||||
|
provider.dispose();
|
||||||
|
});
|
||||||
|
test('dispose ignores late failures and further submissions', () async {
|
||||||
|
var notifications = 0;
|
||||||
|
provider.addListener(() => notifications++);
|
||||||
|
final pending = submit();
|
||||||
|
provider.dispose();
|
||||||
|
service.calls.last.completeError(
|
||||||
|
const AuthException(AuthError.unavailable),
|
||||||
|
);
|
||||||
|
await pending;
|
||||||
|
await submit();
|
||||||
|
expect(notifications, 1);
|
||||||
|
expect(service.calls, hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('dummy services simulate success and configured errors', () async {
|
||||||
|
const login = LoginRequest(
|
||||||
|
email: 'a@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
locale: 'en',
|
||||||
|
);
|
||||||
|
const signup = SignupRequest(
|
||||||
|
name: 'Alex',
|
||||||
|
email: 'a@example.com',
|
||||||
|
password: 'secret',
|
||||||
|
locale: 'ru',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await const DummyLoginService(delay: Duration.zero).login(login))
|
||||||
|
.isSimulated,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
||||||
|
.isSimulated,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
const DummyLoginService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
failure: AuthError.invalidCredentials,
|
||||||
|
).login(login),
|
||||||
|
throwsA(
|
||||||
|
isA<AuthException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
AuthError.invalidCredentials,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
const DummySignupService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
failure: AuthError.emailInUse,
|
||||||
|
).signup(signup),
|
||||||
|
throwsA(
|
||||||
|
isA<AuthException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
AuthError.emailInUse,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
class PendingService implements VerificationService {
|
||||||
|
final pending = Completer<VerificationResult>();
|
||||||
|
int calls = 0;
|
||||||
|
String? email;
|
||||||
|
String? locale;
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
}) {
|
||||||
|
calls++;
|
||||||
|
this.email = email;
|
||||||
|
this.locale = locale;
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> confirm({required String token}) {
|
||||||
|
calls++;
|
||||||
|
return pending.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final scenario in {
|
||||||
|
'demo-valid': VerificationStatus.verified,
|
||||||
|
'demo-expired': VerificationStatus.expired,
|
||||||
|
'bad': VerificationStatus.invalid,
|
||||||
|
'': VerificationStatus.invalid,
|
||||||
|
'demo-internal': VerificationStatus.error,
|
||||||
|
}.entries) {
|
||||||
|
test('confirmation maps ${scenario.key} to ${scenario.value}', () async {
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
const DummyVerificationService(delay: Duration.zero),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.confirm(scenario.key);
|
||||||
|
expect(provider.state.status, scenario.value);
|
||||||
|
expect(
|
||||||
|
provider.state.isSimulated,
|
||||||
|
scenario.value == VerificationStatus.verified,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test(
|
||||||
|
'send trims email, forwards locale and suppresses duplicate requests',
|
||||||
|
() async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(service);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
final first = provider.send(email: ' alex@example.com ', locale: 'ru');
|
||||||
|
expect(provider.state.status, VerificationStatus.sending);
|
||||||
|
await provider.send(email: 'other@example.com', locale: 'en');
|
||||||
|
expect(service.calls, 1);
|
||||||
|
expect(service.email, 'alex@example.com');
|
||||||
|
expect(service.locale, 'ru');
|
||||||
|
service.pending.complete(const VerificationResult(isSimulated: true));
|
||||||
|
await first;
|
||||||
|
expect(provider.state.status, VerificationStatus.sent);
|
||||||
|
expect(provider.state.isSimulated, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('reset discards late result', () async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(service);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
final operation = provider.confirm('token');
|
||||||
|
provider.reset();
|
||||||
|
service.pending.complete(const VerificationResult(isSimulated: true));
|
||||||
|
await operation;
|
||||||
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
});
|
||||||
|
test('dispose discards late exception', () async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(service);
|
||||||
|
final operation = provider.confirm('token');
|
||||||
|
provider.dispose();
|
||||||
|
service.pending.completeError(StateError('private error'));
|
||||||
|
await operation;
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'send errors are internal errors even for token exception types',
|
||||||
|
() async {
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
sendFailure: VerificationError.expired,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.send(email: 'alex@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
|
example/pubspec.lock
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- Introduce light and dark Material themes derived from the SXCHNGE website.
|
||||||
|
- Bundle Onest with its SIL Open Font License.
|
||||||
|
- Export brand colors and theme factories.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# sxchnge_theme
|
||||||
|
|
||||||
|
Installable SXCHNGE theme package for Flutter 3.47+ / Dart 3.13+. It provides light and dark Material 3 themes, website brand colors, and bundled Onest typography with Latin and Cyrillic support. No runtime network requests or third-party packages are needed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
From an application at `frontend/app`, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
sxchnge_theme:
|
||||||
|
path: ../packages/sxchnge_theme
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust the path relative to your app's `pubspec.yaml`, then run `flutter pub get`. This is a private package (`publish_to: none`), ready for local use; it has not been published to pub.dev.
|
||||||
|
|
||||||
|
## Apply
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
MaterialApp(
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
darkTheme: SxchngeTheme.dark(),
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
home: const Scaffold(body: Center(child: Text('Hello, sxchnge'))),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Onest is declared by the package and the theme uses its package-qualified font family, so the consuming app needs no additional font declarations. The bundled SIL Open Font License is in `assets/fonts/OFL.txt`. Follow Flutter's [TextStyle package font guidance](https://api.flutter.dev/flutter/painting/TextStyle/package.html) when creating standalone text styles outside the theme.
|
||||||
|
|
||||||
|
Use `Theme.of(context).colorScheme` and `textTheme` for normal application widgets. `SxchngeBrand` exposes the original palette for intentional brand treatments. Customize locally with `SxchngeTheme.light().copyWith(...)`.
|
||||||
|
|
||||||
|
## Design mapping
|
||||||
|
|
||||||
|
- Ink, mint, lime, paper, and forest match `website/design/tokens.json` version 1.
|
||||||
|
- Light mode uses ink actions on paper; dark mode uses mint actions on ink.
|
||||||
|
- Mint and lime containers use ink foregrounds. Lime is a brand accent, not a success or transaction status.
|
||||||
|
- Onest body text starts at 16 logical pixels, with 14-pixel secondary text. Text scaling is preserved.
|
||||||
|
- Cards and dialogs use 24-pixel corners, fields use 14-pixel corners, and buttons use pill shapes with at least 48-pixel minimum height.
|
||||||
|
- Error colors remain Material semantic roles. A future product status system should define success, warning, and information roles separately.
|
||||||
|
|
||||||
|
The palette is a package-owned snapshot: website builds do not modify this package. Deliberate brand updates should update the snapshot and package version together. Campaign layout, rotation, marquee, and decorative motion are not part of the application theme.
|
||||||
|
|
||||||
|
## Example and validation
|
||||||
|
|
||||||
|
`example/lib/main.dart` is a minimal consumer gallery with English/Russian text, cards, inputs, validation errors, and enabled/disabled buttons. To launch it, generate the platform runner you need from `example/`, for example `flutter create --platforms=web .`, then `flutter run -d chrome`. Platform runners are not included in this theme-only package.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
From this package and its `example/` folder, run `flutter analyze`. Tests cover core brand synchronization in this repository, foreground contrast in both modes, package font/license bundling in an independent consumer, and rendering/input on a narrow screen at 200% text scale.
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
void main() => runApp(const ThemeExample());
|
||||||
|
|
||||||
|
class ThemeExample extends StatelessWidget {
|
||||||
|
const ThemeExample({super.key, this.mode = ThemeMode.system});
|
||||||
|
|
||||||
|
final ThemeMode mode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: SxchngeTheme.light(),
|
||||||
|
darkTheme: SxchngeTheme.dark(),
|
||||||
|
themeMode: mode,
|
||||||
|
home: const _Gallery(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Gallery extends StatelessWidget {
|
||||||
|
const _Gallery();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('sxchnge')),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Your next exchange',
|
||||||
|
style: Theme.of(context).textTheme.headlineLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Обмен без лишних шагов.\nA preview of the SXCHNGE theme.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text('Clear amounts. Clear choices.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Amount',
|
||||||
|
hintText: '0.00',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
errorText: 'Enter a valid email address',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton(onPressed: () {}, child: const Text('Continue')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton(onPressed: () {}, child: const Text('Back')),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const FilledButton(onPressed: null, child: Text('Unavailable')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: sxchnge_theme_example
|
||||||
|
description: Minimal consumer and component gallery for the SXCHNGE theme.
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
sxchnge_theme:
|
||||||
|
path: ../
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme_example/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('consumer bundles the package font and its license', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(const ThemeExample());
|
||||||
|
final manifest =
|
||||||
|
jsonDecode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
final family = manifest.cast<Map>().singleWhere(
|
||||||
|
(font) => font['family'] == 'packages/sxchnge_theme/Onest',
|
||||||
|
);
|
||||||
|
final asset = (family['fonts'] as List).first['asset'] as String;
|
||||||
|
expect((await rootBundle.load(asset)).lengthInBytes, greaterThan(1000));
|
||||||
|
expect(
|
||||||
|
await rootBundle.loadString(
|
||||||
|
'packages/sxchnge_theme/assets/fonts/OFL.txt',
|
||||||
|
),
|
||||||
|
contains('SIL OPEN FONT LICENSE'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
||||||
|
testWidgets(
|
||||||
|
'$mode renders on a narrow screen with large text and accepts input',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
await tester.pumpWidget(ThemeExample(mode: mode));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.enterText(find.byType(TextField).first, '125.50');
|
||||||
|
expect(find.text('125.50'), findsOneWidget);
|
||||||
|
await tester.ensureVisible(find.text('Continue'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Brand snapshot from website/design/tokens.json (version 1).
|
||||||
|
// Keep these values aligned when deliberately updating the package's brand.
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
abstract final class SxchngeBrand {
|
||||||
|
static const ink = Color(0xFF14221D);
|
||||||
|
static const mint = Color(0xFFC4F9CE);
|
||||||
|
static const lime = Color(0xFFD7FF5F);
|
||||||
|
static const paper = Color(0xFFF9FAF6);
|
||||||
|
static const muted = Color(0xFF637168);
|
||||||
|
static const line = Color(0xFFD9E0D8);
|
||||||
|
static const forest = Color(0xFF396A4B);
|
||||||
|
static const focus = Color(0xFF168850);
|
||||||
|
static const darkField = Color(0xFF25372E);
|
||||||
|
static const darkReceive = Color(0xFF203D2D);
|
||||||
|
static const sageSurface = Color(0xFFEAF0E6);
|
||||||
|
static const blueSurface = Color(0xFFEDF0F9);
|
||||||
|
static const fontFamily = 'Onest';
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'brand.dart';
|
||||||
|
|
||||||
|
/// Install with `MaterialApp(theme: SxchngeTheme.light(),
|
||||||
|
/// darkTheme: SxchngeTheme.dark())`.
|
||||||
|
abstract final class SxchngeTheme {
|
||||||
|
static const packageName = 'sxchnge_theme';
|
||||||
|
|
||||||
|
static ThemeData light() => _build(Brightness.light);
|
||||||
|
static ThemeData dark() => _build(Brightness.dark);
|
||||||
|
|
||||||
|
static ThemeData _build(Brightness brightness) {
|
||||||
|
final dark = brightness == Brightness.dark;
|
||||||
|
// Seed supplies complete Material roles; explicit overrides preserve brand
|
||||||
|
// colors and readable foregrounds rather than approximating the palette.
|
||||||
|
final scheme =
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: SxchngeBrand.forest,
|
||||||
|
brightness: brightness,
|
||||||
|
).copyWith(
|
||||||
|
primary: dark ? SxchngeBrand.mint : SxchngeBrand.ink,
|
||||||
|
onPrimary: dark ? SxchngeBrand.ink : SxchngeBrand.mint,
|
||||||
|
primaryContainer: SxchngeBrand.mint,
|
||||||
|
onPrimaryContainer: SxchngeBrand.ink,
|
||||||
|
secondary: dark ? SxchngeBrand.mint : SxchngeBrand.forest,
|
||||||
|
onSecondary: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
|
||||||
|
secondaryContainer: dark
|
||||||
|
? SxchngeBrand.darkReceive
|
||||||
|
: SxchngeBrand.sageSurface,
|
||||||
|
onSecondaryContainer: dark ? SxchngeBrand.mint : SxchngeBrand.ink,
|
||||||
|
tertiary: dark ? SxchngeBrand.lime : SxchngeBrand.forest,
|
||||||
|
onTertiary: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
|
||||||
|
tertiaryContainer: SxchngeBrand.lime,
|
||||||
|
onTertiaryContainer: SxchngeBrand.ink,
|
||||||
|
surface: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
|
||||||
|
onSurface: dark ? SxchngeBrand.paper : SxchngeBrand.ink,
|
||||||
|
onSurfaceVariant: dark ? const Color(0xFFB6C6B9) : SxchngeBrand.muted,
|
||||||
|
surfaceContainerLowest: dark ? const Color(0xFF101B17) : Colors.white,
|
||||||
|
surfaceContainerLow: dark
|
||||||
|
? const Color(0xFF1B2B23)
|
||||||
|
: SxchngeBrand.paper,
|
||||||
|
surfaceContainer: dark
|
||||||
|
? SxchngeBrand.darkField
|
||||||
|
: SxchngeBrand.sageSurface,
|
||||||
|
surfaceContainerHigh: dark
|
||||||
|
? SxchngeBrand.darkReceive
|
||||||
|
: SxchngeBrand.line,
|
||||||
|
surfaceContainerHighest: dark
|
||||||
|
? const Color(0xFF324A3C)
|
||||||
|
: const Color(0xFFCFD9CE),
|
||||||
|
outline: dark ? const Color(0xFF8CA291) : SxchngeBrand.muted,
|
||||||
|
outlineVariant: dark ? const Color(0xFF405A48) : SxchngeBrand.line,
|
||||||
|
inverseSurface: dark ? SxchngeBrand.paper : SxchngeBrand.ink,
|
||||||
|
onInverseSurface: dark ? SxchngeBrand.ink : SxchngeBrand.paper,
|
||||||
|
inversePrimary: dark ? SxchngeBrand.forest : SxchngeBrand.mint,
|
||||||
|
surfaceTint: Colors.transparent,
|
||||||
|
);
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
fontFamily: SxchngeBrand.fontFamily,
|
||||||
|
package: packageName,
|
||||||
|
scaffoldBackgroundColor: scheme.surface,
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||||
|
);
|
||||||
|
final text = base.textTheme.copyWith(
|
||||||
|
displayLarge: base.textTheme.displayLarge!.copyWith(
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -2,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
displayMedium: base.textTheme.displayMedium!.copyWith(
|
||||||
|
fontSize: 40,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -1.5,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
displaySmall: base.textTheme.displaySmall!.copyWith(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -1,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineLarge: base.textTheme.headlineLarge!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -1,
|
||||||
|
),
|
||||||
|
headlineMedium: base.textTheme.headlineMedium!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
headlineSmall: base.textTheme.headlineSmall!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
bodyLarge: base.textTheme.bodyLarge!.copyWith(fontSize: 16, height: 1.65),
|
||||||
|
bodyMedium: base.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodySmall: base.textTheme.bodySmall!.copyWith(fontSize: 14, height: 1.5),
|
||||||
|
labelLarge: base.textTheme.labelLarge!.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
labelMedium: base.textTheme.labelMedium!.copyWith(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
labelSmall: base.textTheme.labelSmall!.copyWith(fontSize: 14),
|
||||||
|
);
|
||||||
|
final border = OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: BorderSide(color: scheme.outline),
|
||||||
|
);
|
||||||
|
return base.copyWith(
|
||||||
|
textTheme: text,
|
||||||
|
appBarTheme: AppBarThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
foregroundColor: scheme.onSurface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
titleTextStyle: text.titleLarge,
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
elevation: 0,
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size(64, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: text.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
minimumSize: const Size(64, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: text.labelLarge,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(64, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: text.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: const Size(48, 48),
|
||||||
|
textStyle: text.labelLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationThemeData(
|
||||||
|
filled: true,
|
||||||
|
fillColor: scheme.surfaceContainerLowest,
|
||||||
|
contentPadding: const EdgeInsets.all(16),
|
||||||
|
border: border,
|
||||||
|
enabledBorder: border,
|
||||||
|
focusedBorder: border.copyWith(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: dark ? SxchngeBrand.mint : SxchngeBrand.focus,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBorder: border.copyWith(
|
||||||
|
borderSide: BorderSide(color: scheme.error),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: border.copyWith(
|
||||||
|
borderSide: BorderSide(color: scheme.error, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
),
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: scheme.outlineVariant,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: scheme.surface,
|
||||||
|
indicatorColor: scheme.primaryContainer,
|
||||||
|
),
|
||||||
|
textSelectionTheme: TextSelectionThemeData(
|
||||||
|
cursorColor: scheme.primary,
|
||||||
|
selectionColor: SxchngeBrand.lime.withValues(alpha: dark ? 0.3 : 0.5),
|
||||||
|
selectionHandleColor: scheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/// SXCHNGE brand colors, typography, and Material themes.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/brand.dart';
|
||||||
|
export 'src/theme.dart';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: sxchnge_theme
|
||||||
|
description: SXCHNGE Material theme and bundled Onest typography for Flutter apps.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
flutter: '>=3.47.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
assets:
|
||||||
|
- assets/fonts/OFL.txt
|
||||||
|
fonts:
|
||||||
|
- family: Onest
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Onest-Variable.ttf
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
double contrast(Color a, Color b) {
|
||||||
|
final x = a.computeLuminance();
|
||||||
|
final y = b.computeLuminance();
|
||||||
|
return (math.max(x, y) + 0.05) / (math.min(x, y) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('website core colors stay aligned with the package snapshot', () {
|
||||||
|
final source = File('../../../website/design/tokens.json');
|
||||||
|
if (!source.existsSync())
|
||||||
|
return; // Also allow testing outside this monorepo.
|
||||||
|
final colors =
|
||||||
|
(jsonDecode(source.readAsStringSync()) as Map)['colors'] as Map;
|
||||||
|
for (final entry in {
|
||||||
|
'ink': SxchngeBrand.ink,
|
||||||
|
'mint': SxchngeBrand.mint,
|
||||||
|
'lime': SxchngeBrand.lime,
|
||||||
|
'paper': SxchngeBrand.paper,
|
||||||
|
'forest': SxchngeBrand.forest,
|
||||||
|
}.entries) {
|
||||||
|
expect(
|
||||||
|
entry.value.toARGB32(),
|
||||||
|
int.parse('ff${(colors[entry.key] as String).substring(1)}', radix: 16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final dark in [false, true]) {
|
||||||
|
final theme = dark ? SxchngeTheme.dark() : SxchngeTheme.light();
|
||||||
|
test(
|
||||||
|
'${dark ? "dark" : "light"} foreground pairs meet normal text contrast',
|
||||||
|
() {
|
||||||
|
final c = theme.colorScheme;
|
||||||
|
for (final pair in [
|
||||||
|
(c.primary, c.onPrimary),
|
||||||
|
(c.primaryContainer, c.onPrimaryContainer),
|
||||||
|
(c.secondary, c.onSecondary),
|
||||||
|
(c.secondaryContainer, c.onSecondaryContainer),
|
||||||
|
(c.tertiary, c.onTertiary),
|
||||||
|
(c.tertiaryContainer, c.onTertiaryContainer),
|
||||||
|
(c.surface, c.onSurface),
|
||||||
|
(c.surface, c.onSurfaceVariant),
|
||||||
|
(c.error, c.onError),
|
||||||
|
(c.errorContainer, c.onErrorContainer),
|
||||||
|
]) {
|
||||||
|
expect(contrast(pair.$1, pair.$2), greaterThanOrEqualTo(4.5));
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
theme.textTheme.bodyMedium!.fontFamily,
|
||||||
|
'packages/sxchnge_theme/Onest',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,24 @@
|
|||||||
|
## Development
|
||||||
|
|
||||||
|
The public Astro site is in `website/`. Run its npm and Astro commands from that folder. `frontend/` and `backend/` are reserved for the new consumer application and BFF.
|
||||||
|
|
||||||
|
When starting the dev server, use background mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
astro dev --background
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Full documentation: https://docs.astro.build
|
||||||
|
|
||||||
|
Consult these guides before working on related tasks:
|
||||||
|
|
||||||
|
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
|
||||||
|
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
|
||||||
|
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
|
||||||
|
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
|
||||||
|
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
|
||||||
|
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SXCHNGE
|
||||||
|
|
||||||
|
Astro public website for a crypto-to-fiat exchange. English and Russian, responsive layouts, an illustrative exchange calculator, and explicit company / policy placeholders.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Node >=22.12.0 (Node >=22.18 for the native TypeScript test runner), npm.
|
||||||
|
|
||||||
|
Run these commands from `website/` (use `cd website` from the repository root).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run dev -- --background --host 127.0.0.1
|
||||||
|
npm run test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `npm exec astro dev stop` to stop the background preview. Static output is `dist/`. `/` redirects to `/en/`; localized content lives at `/en/` and `/ru/`. Legal placeholders have separate localized routes.
|
||||||
|
|
||||||
|
## Content and launch configuration
|
||||||
|
|
||||||
|
- Translation source: `src/i18n/copy.ts`.
|
||||||
|
- Brand reference: `design/tokens.json`, documented in `design/README.md`.
|
||||||
|
- Optional Flutter palette reference: `flutter/brand_palette.dart`. Compatibility does not require identical application styling.
|
||||||
|
- Full account URLs: set `PUBLIC_FLUTTER_REGISTER_URL` and `PUBLIC_FLUTTER_LOGIN_URL` at build time, as shown in `.env.example`. Locale is appended as `lang`. Until configured, actions open a clear preview dialog; no credentials are collected.
|
||||||
|
- Quote fixtures: `src/lib/quote.ts`. Demo only; no rates are fetched, orders placed, or funds handled.
|
||||||
|
- Replace all company placeholders, supply approved legal policies, and confirm supported assets, payout methods, countries, fees and timelines before launch.
|
||||||
|
- The preview explicitly uses `noindex,nofollow`. Remove only when launch content is complete, and set the real domain in `astro.config.mjs`.
|
||||||
|
|
||||||
|
Fonts are self-hosted with their license. No analytics, third-party trackers, client storage, or authentication backend is included. The website uses Astro's own TypeScript script support; React is unnecessary for the current interactions.
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user