Prepare product layout and reusable Flutter theme

Relocate the existing Astro website and preserve its assets and hosting configuration. Add the consumer/backend scaffold and themed Flutter package with consumer tests. Related to #1.
This commit is contained in:
Stephan D committed 2026-09-11 15:36:58 +02:00
1 parent ef65a40213
commit d9318053e8
46 files changed
+703 -1

No files matched your search

+13
View File
@@ -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,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.
+58
View File
@@ -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.
@@ -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.
@@ -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',
);
},
);
}
}