Added registration page to app, username availability checking.

This commit is contained in:
Andrew Lalis 2024-09-11 16:15:53 -04:00
parent 0362fe3323
commit 270dcc6020
27 changed files with 825 additions and 140 deletions

15
finnow-api/README.md Normal file
View File

@ -0,0 +1,15 @@
# Finnow API
The Finnow API is primarily implemented as an HTTP REST API using D, and the [handy-httpd](https://code.dlang.org/packages/handy-httpd) library.
## Architecture
This project is set up as a _modular monolith_, where the API as a whole is broken up into mostly-independent modules. Each module can be found under `source/`, like `source/auth` for example.
Within each module, you'll usually find some of the following submodules:
* `model.d` - Defines models for this module, often database entities.
* `data.d` - Defines the data access interfaces and associated types, so that other modules can interact with it.
* `data_impl_*.d` - A concrete implementation of a submodule's data access interfaces, often using a specific technology or platform.
* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework.
* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules.

View File

@ -7,7 +7,9 @@
"asdf": "~>0.7.17",
"botan": "~>1.13.6",
"d2sqlite3": "~>1.0.0",
"handy-httpd": "~>8.4.0",
"handy-httpd": {
"path": "/home/andrew/Code/github-andrewlalis/handy-httpd"
},
"jwt": "~>0.4.0",
"slf4d": "~>3.0.1"
},

View File

@ -5,7 +5,6 @@
"botan": "1.13.6",
"botan-math": "1.0.4",
"d2sqlite3": "1.0.0",
"handy-httpd": "8.4.0",
"httparsed": "1.2.1",
"jwt": "0.4.0",
"memutils": "1.0.10",

View File

@ -1,11 +1,11 @@
module account.api;
import handy_httpd;
import asdf;
import profile.service;
import account.model;
import money.currency;
import util.json;
struct AccountResponse {
ulong id;
@ -36,7 +36,7 @@ void handleGetAccounts(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
auto accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a));
ctx.response.writeBodyString(serializeToJson(accounts), "application/json");
writeJsonBody(ctx, accounts);
}
void handleGetAccount(ref HttpRequestContext ctx) {
@ -44,7 +44,7 @@ void handleGetAccount(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
auto account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json");
writeJsonBody(ctx, AccountResponse.of(account));
}
struct AccountCreationPayload {
@ -57,14 +57,7 @@ struct AccountCreationPayload {
void handleCreateAccount(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
AccountCreationPayload payload;
try {
payload = deserialize!(AccountCreationPayload)(ctx.request.readBodyAsString());
} catch (SerdeException e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid account payload.");
return;
}
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
AccountType type = AccountType.fromId(payload.type);
Currency currency = Currency.ofCode(payload.currency);
Account account = ds.getAccountRepository().insert(
@ -74,7 +67,7 @@ void handleCreateAccount(ref HttpRequestContext ctx) {
currency,
payload.description
);
ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json");
writeJsonBody(ctx, AccountResponse.of(account));
}
void handleDeleteAccount(ref HttpRequestContext ctx) {

View File

@ -21,6 +21,7 @@ PathHandler mapApiHandlers() {
import auth.api;
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister);
h.addMapping(Method.GET, API_PATH ~ "/register/username-availability", &getUsernameAvailability);
// Authenticated endpoints:
PathHandler a = new PathHandler();
@ -55,4 +56,5 @@ private void getStatus(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online");
}
private void getOptions(ref HttpRequestContext ctx) {}
private void getOptions(ref HttpRequestContext ctx) {
}

View File

@ -1,7 +1,12 @@
import handy_httpd;
import slf4d;
import slf4d.default_provider;
import api_mapping;
void main() {
auto provider = new DefaultProvider(true, Levels.INFO);
configureLoggingProvider(provider);
ServerConfig cfg;
cfg.workerPoolSize = 5;
cfg.port = 8080;

View File

@ -0,0 +1,12 @@
module attachment.data;
import handy_httpd.components.optional;
import attachment.model;
import std.datetime;
interface AttachmentRepository {
Optional!Attachment findById(ulong id);
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content);
void remove(ulong id);
}

View File

@ -0,0 +1,68 @@
module attachment.data_impl_sqlite;
import handy_httpd.components.optional;
import d2sqlite3;
import attachment.model;
import attachment.data;
import util.sqlite;
import std.datetime;
import std.format;
class SqliteAttachmentRepository : AttachmentRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!Attachment findById(ulong id) {
return findOne(
db,
"SELECT * FROM attachment WHERE id = ?",
&parseAttachment,
id
);
}
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) {
const query = format!"SELECT * FROM attachment WHERE id IN (%s)"(subquery);
return findAll(db, query, &parseAttachment, entityId);
}
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content) {
util.sqlite.update(
db,
q"SQL
INSERT INTO attachment
(uploaded_at, filename, content_type, size, content)
VALUES (?, ?, ?, ?, ?)
SQL",
uploadedAt.toISOExtString(),
filename,
contentType,
cast(ulong) content.length,
content
);
return db.lastInsertRowid();
}
void remove(ulong id) {
util.sqlite.update(
db,
"DELETE FROM attachment WHERE id = ?",
id
);
}
static Attachment parseAttachment(Row row) {
return Attachment(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1), UTC()),
row.peek!string(2),
row.peek!string(3),
row.peek!ulong(4),
row.peek!(ubyte[])(5)
);
}
}

View File

@ -4,20 +4,15 @@ module auth.api;
import handy_httpd;
import handy_httpd.components.optional;
import slf4d;
import asdf;
import auth.model;
import auth.data;
import auth.service;
import auth.data_impl_fs;
import util.json;
void postLogin(ref HttpRequestContext ctx) {
LoginCredentials loginCredentials;
try {
loginCredentials = deserialize!(LoginCredentials)(ctx.request.readBodyAsString());
} catch (Exception e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
}
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
if (!validateUsername(loginCredentials.username)) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
return;
@ -29,24 +24,29 @@ void postLogin(ref HttpRequestContext ctx) {
return;
}
import botan.passhash.bcrypt : checkBcrypt;
if (!checkBcrypt(loginCredentials.password, optionalUser.value.passwordHash)) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
return;
}
string token = generateAccessToken(optionalUser.value);
ctx.response.status = HttpStatus.OK;
TokenResponse resp = TokenResponse(token);
ctx.response.writeBodyString(serializeToJson(resp), "application/json");
writeJsonBody(ctx, TokenResponse(token));
}
void getUsernameAvailability(ref HttpRequestContext ctx) {
Optional!string username = ctx.request.queryParams.getFirst("username");
if (username.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Missing username parameter.");
return;
}
UserRepository userRepo = new FileSystemUserRepository();
bool available = userRepo.findByUsername(username.value).isNull;
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
}
void postRegister(ref HttpRequestContext ctx) {
RegistrationData registrationData;
try {
registrationData = deserialize!(RegistrationData)(ctx.request.readBodyAsString());
} catch (Exception e) {
ctx.response.status = HttpStatus.BAD_REQUEST;
return;
}
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
if (!validateUsername(registrationData.username)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid username.");
@ -66,10 +66,13 @@ void postRegister(ref HttpRequestContext ctx) {
import botan.passhash.bcrypt : generateBcrypt;
import botan.rng.auto_rng;
RandomNumberGenerator rng = new AutoSeededRNG();
string passwordHash = generateBcrypt(registrationData.password, rng, 12);
userRepo.createUser(registrationData.username, passwordHash);
User user = userRepo.createUser(registrationData.username, passwordHash);
infoF!"Created user: %s"(registrationData.username);
string token = generateAccessToken(user);
writeJsonBody(ctx, TokenResponse(token));
}
void getMyUser(ref HttpRequestContext ctx) {

View File

@ -14,6 +14,10 @@ struct TokenResponse {
string token;
}
struct UsernameAvailabilityResponse {
bool available;
}
struct RegistrationData {
string username;
string password;

View File

@ -11,6 +11,7 @@ import profile.data;
import profile.data_impl_sqlite;
import auth.model;
import auth.service;
import util.json;
void handleCreateNewProfile(ref HttpRequestContext ctx) {
JSONValue obj = ctx.request.readBodyAsJson();
@ -29,7 +30,7 @@ void handleGetProfiles(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
Profile[] profiles = profileRepo.findAll();
ctx.response.writeBodyString(serializeToJson(profiles), "application/json");
writeJsonBody(ctx, profiles);
}
void handleDeleteProfile(ref HttpRequestContext ctx) {
@ -50,5 +51,5 @@ void handleGetProperties(ref HttpRequestContext ctx) {
ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile);
auto propsRepo = ds.getPropertiesRepository();
ProfileProperty[] props = propsRepo.findAll();
ctx.response.writeBodyString(serializeToJson(props), "application/json");
writeJsonBody(ctx, props);
}

View File

@ -11,6 +11,7 @@ interface ProfileRepository {
ProfileDataSource getDataSource(in Profile profile);
}
/// Repository for accessing the properties of a profile.
interface PropertiesRepository {
Optional!string findProperty(string propertyName);
void setProperty(string name, string value);

View File

@ -0,0 +1,41 @@
/// Utilities for reading and writing JSON in HTTP request contexts.
module util.json;
import handy_httpd;
import slf4d;
import asdf;
/**
* Reads a JSON payload into a type T. Throws an `HttpStatusException` if
* the data cannot be read or converted to the given type, with a 400 BAD
* REQUEST status.
* Params:
* ctx = The request context to read from.
* Returns: The data that was read.
*/
T readJsonPayload(T)(ref HttpRequestContext ctx) {
try {
string requestBody = ctx.request.readBodyAsString();
return deserialize!T(requestBody);
} catch (SerdeException e) {
debug_("Got an exception while deserializing a request body.", e);
throw new HttpStatusException(HttpStatus.BAD_REQUEST);
}
}
/**
* Writes data of type T to a JSON response body. Throws an `HttpStatusException`
* with status 501 INTERNAL SERVER ERROR if serialization fails.
* Params:
* ctx = The request context to write to.
* data = The data to write.
*/
void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) {
try {
string jsonStr = serializeToJson(data);
ctx.response.writeBodyString(jsonStr, "application/json");
} catch (SerdeException e) {
debug_("Exception while serializing a response body.", e);
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@ -0,0 +1,78 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class LoginCredentials {
final String username;
final String password;
const LoginCredentials(this.username, this.password);
Map<String, dynamic> toJson() {
return {
'username': username,
'password': password,
};
}
}
class TokenResponse {
final String token;
const TokenResponse(this.token);
factory TokenResponse.fromJson(Map<String, dynamic> json) {
return switch (json) {
{'token': String token} => TokenResponse(token),
_ => throw const FormatException('Invalid token response format.'),
};
}
}
Future<String> postLogin(LoginCredentials credentials) async {
final http.Response response = await http.post(
Uri.parse('http://localhost:8080/api/login'),
body: jsonEncode(credentials.toJson()),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final TokenResponse obj = TokenResponse.fromJson(data);
return obj.token;
} else {
throw Exception('Failed to log in.');
}
}
Future<bool> getUsernameAvailability(String username) async {
final http.Response response = await http.get(
Uri.parse('http://localhost:8080/api/register/username-availability?username=$username'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data['available'] as bool;
} else {
return false;
}
}
Future<String> postRegister(LoginCredentials credentials) async {
final bodyContent = jsonEncode(credentials.toJson());
final http.Response response = await http.post(
Uri.parse('http://localhost:8080/api/register'),
body: bodyContent,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Content-Length': bodyContent.length.toString()
}
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return TokenResponse.fromJson(data).token;
} else {
print(response);
throw Exception('Registration failed.');
}
}

View File

@ -0,0 +1,4 @@
class FinnowApi {
}

View File

@ -0,0 +1,31 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class Profile {
final String name;
const Profile(this.name);
factory Profile.fromJson(Map<String, dynamic> json) {
return switch(json) {
{'name': String name} => Profile(name),
_ => throw const FormatException('Invalid profile object.')
};
}
}
Future<List<Profile>> getProfiles(String token) async {
final http.Response response = await http.get(
Uri.parse('http://localhost:8080/api/profiles'),
headers: {
'Authorization': 'Bearer $token'
}
);
if (response.statusCode == 200) {
if (jsonDecode(response.body) == null) return []; // Workaround for bad array serialization in the API.
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((obj) => Profile.fromJson(obj)).toList();
} else {
throw Exception('Failed to get profiles.');
}
}

37
flutter_app/lib/app.dart Normal file
View File

@ -0,0 +1,37 @@
import 'package:finnow_app/main.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:watch_it/watch_it.dart';
import 'auth/model.dart';
/// The main Finnow application.
class FinnowApp extends StatelessWidget with WatchItMixin {
const FinnowApp({super.key});
@override
Widget build(BuildContext context) {
final router = getIt<GoRouter>();
// We add a top-level listener for anytime the authentication model changes.
// If it does, we need to refresh navigation.
registerChangeNotifierHandler(handler:(context, AuthenticationModel newValue, cancel) {
if (newValue.state.authenticated()) {
router.replace('/profiles');
} else {
while (router.canPop()) {
router.pop();
}
router.pushReplacement('/login');
}
},);
return MaterialApp.router(
routerConfig: router,
title: 'Finnow',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
));
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
sealed class AuthenticationState {
const AuthenticationState();
bool authenticated();
}
class Unauthenticated extends AuthenticationState {
@override
bool authenticated() {
return false;
}
}
class Authenticated extends AuthenticationState {
final String token;
final String username;
const Authenticated(this.token, this.username);
@override
bool authenticated() {
return true;
}
}
class AuthenticationModel extends ChangeNotifier {
AuthenticationState _state = Unauthenticated();
AuthenticationState get state => _state;
set state(AuthenticationState newState) {
_state = newState;
notifyListeners();
}
}
String? usernameValidator(String? value) {
if (value == null || value.trim().length < 3) {
return 'Please enter a valid username.';
}
return null;
}
String? passwordValidator(String? value) {
if (value == null || value.length < 8) {
return 'Please enter a valid password.';
}
return null;
}

View File

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
/// A simple left-aligned, bold text widget intended to be placed above form
/// input widgets.
class FormLabel extends StatelessWidget {
final String text;
const FormLabel(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)));
}
}

View File

@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
class TitleText extends StatelessWidget {
final String text;
const TitleText(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
);
}
}

View File

@ -1,4 +1,11 @@
import 'package:finnow_app/auth/model.dart';
import 'package:finnow_app/components/form_label.dart';
import 'package:finnow_app/components/title_text.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'api/auth.dart';
import 'main.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@ -36,49 +43,35 @@ class _LoginFormState extends State<LoginForm> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Username'),
const TitleText('Login to Finnow'),
const SizedBox(height: 20),
const FormLabel('Username'),
TextFormField(
controller: usernameTextController,
decoration: const InputDecoration(
hintText: 'Enter username', border: OutlineInputBorder()),
validator: (value) {
if (value == null || value.trim().length < 3) {
return 'Please enter a valid username.';
}
return null;
},
validator: usernameValidator,
),
const SizedBox(height: 10),
const Text('Password'),
const FormLabel('Password'),
TextFormField(
controller: passwordTextController,
obscureText: true,
decoration: const InputDecoration(
hintText: 'Enter password', border: OutlineInputBorder()),
validator: (value) {
if (value == null || value.length < 8) {
return 'Please enter a valid password.';
}
return null;
},
validator: passwordValidator,
),
const SizedBox(height: 10),
if (!loginFailed)
TextButton(onPressed: onLoginPressed, child: const Text('Login')),
if (loginFailed) ...[
const SizedBox(height: 10),
TextButton(
onPressed: () {
if (formKey.currentState!.validate()) {
print(usernameTextController.text);
print(passwordTextController.text);
} else {
setState(() => loginFailed = true);
Future.delayed(const Duration(seconds: 3),
() => setState(() => loginFailed = false));
}
},
child: const Text('Login')),
if (loginFailed)
onPressed: () {}, child: const Text('Forgot password?'))
],
TextButton(
onPressed: () {}, child: const Text('Forgot password?')),
TextButton(onPressed: () {}, child: const Text('Create an Account'))
onPressed: onCreateAccountPressed,
child: const Text('Create an Account'))
],
));
}
@ -89,4 +82,30 @@ class _LoginFormState extends State<LoginForm> {
passwordTextController.dispose();
super.dispose();
}
void onLoginPressed() async {
if (formKey.currentState!.validate()) {
final credentials = LoginCredentials(
usernameTextController.text, passwordTextController.text);
try {
String token = await postLogin(credentials);
getIt<AuthenticationModel>().state = Authenticated(token, credentials.username);
} catch (e) {
print(e);
onLoginAttemptFailed();
}
} else {
onLoginAttemptFailed();
}
}
void onLoginAttemptFailed() async {
setState(() => loginFailed = true);
Future.delayed(
const Duration(seconds: 3), () => setState(() => loginFailed = false));
}
void onCreateAccountPressed() async {
getIt<GoRouter>().go('/register');
}
}

View File

@ -1,70 +1,64 @@
import 'package:finnow_app/login_page.dart';
import 'package:finnow_app/api/main.dart';
import 'package:finnow_app/auth/model.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:watch_it/watch_it.dart';
import 'app.dart';
import 'login_page.dart';
import 'profiles_page.dart';
import 'register_page.dart';
final getIt = GetIt.instance;
void main() {
setup();
runApp(const FinnowApp());
}
class FinnowApp extends StatelessWidget {
const FinnowApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Finnow',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const LoginPage(),
);
}
void setup() {
getIt.registerSingleton<FinnowApi>(FinnowApi());
getIt.registerSingleton<AuthenticationModel>(AuthenticationModel());
getIt.registerSingleton<GoRouter>(getRouterConfig());
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
GoRouter getRouterConfig() {
return GoRouter(routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(
path: '/register', builder: (context, state) => const RegisterPage()),
// Once a user has logged in, they're directed to a scaffold for the /profiles page.
ShellRoute(
builder: (context, state, child) => Scaffold(
body: child,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
title: const Text('Finnow'),
backgroundColor: Colors.grey,
actions: [
TextButton(
onPressed: () => getIt<AuthenticationModel>().state =
Unauthenticated(),
child: const Text('Logout'))
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
redirect: (context, state) {
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? null : '/login';
},
routes: [
GoRoute(
path: '/',
redirect: (context, state) {
final bool authenticated =
getIt<AuthenticationModel>().state.authenticated();
return authenticated ? '/profiles' : '/login';
}),
GoRoute(
path: '/profiles',
builder: (context, state) => const ProfilesPage(),
)
]),
]);
}

View File

@ -0,0 +1,60 @@
import 'package:finnow_app/auth/model.dart';
import 'package:flutter/material.dart';
import 'api/profile.dart';
import 'main.dart';
class ProfilesPage extends StatelessWidget {
const ProfilesPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: const Padding(
padding: EdgeInsets.all(8.0),
child: Column(children: [
Text('Profiles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
SizedBox(height: 10),
Expanded(child: _ProfilesListView()),
])),
floatingActionButton: FloatingActionButton(
onPressed: () => print('pressed'),
child: const Icon(Icons.add)
),
);
}
}
class _ProfilesListView extends StatefulWidget {
const _ProfilesListView();
@override
State<_ProfilesListView> createState() => __ProfilesListViewState();
}
class __ProfilesListViewState extends State<_ProfilesListView> {
List<Profile> profiles = List.empty();
@override
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
children: profiles.map((p) => Text('Profile: ${p.name}')).toList(),
);
}
@override
void initState() {
super.initState();
refreshProfiles();
}
void refreshProfiles() async {
setState(() => profiles = List.empty());
final authState = getIt<AuthenticationModel>().state;
if (authState is Authenticated) {
final List<Profile> latestProfiles = await getProfiles(authState.token);
setState(() => profiles = latestProfiles);
}
}
}

View File

@ -0,0 +1,139 @@
import 'package:finnow_app/api/auth.dart';
import 'package:finnow_app/auth/model.dart';
import 'package:finnow_app/components/form_label.dart';
import 'package:finnow_app/components/title_text.dart';
import 'package:finnow_app/main.dart';
import 'package:finnow_app/util/debouncer.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class RegisterPage extends StatelessWidget {
const RegisterPage({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 300.0),
padding: const EdgeInsets.all(10),
child: const RegisterForm())));
}
}
class RegisterForm extends StatefulWidget {
const RegisterForm({super.key});
@override
State<RegisterForm> createState() => _RegisterFormState();
}
class _RegisterFormState extends State<RegisterForm> {
final formKey = GlobalKey<FormState>();
final usernameTextController = TextEditingController();
final passwordTextController = TextEditingController();
var loading = false;
final usernameAvailabilityDebouncer = Debouncer();
bool? usernameAvailable;
var canCreateAccount = false;
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const TitleText('Create a Finnow Account'),
const SizedBox(height: 20),
const FormLabel('Username'),
TextFormField(
controller: usernameTextController,
onChanged: (s) {
formValuesUpdated();
usernameAvailabilityDebouncer
.run(() => checkUsernameAvailability());
},
decoration: const InputDecoration(
hintText: 'Enter username', border: OutlineInputBorder()),
validator: usernameValidator,
),
if (usernameAvailable != null && usernameAvailable == true) ...[
const Text('Username is available.',
style: TextStyle(color: Colors.green))
],
if (usernameAvailable != null && usernameAvailable == false) ...[
const Text('Username is taken.',
style: TextStyle(color: Colors.red))
],
const SizedBox(
height: 10,
),
const FormLabel('Password'),
TextFormField(
controller: passwordTextController,
onChanged: (s) => formValuesUpdated(),
obscureText: true,
decoration: const InputDecoration(
hintText: 'Enter password', border: OutlineInputBorder()),
validator: passwordValidator),
const SizedBox(height: 10),
TextButton(
onPressed: loading || !canCreateAccount ? null : createAccount,
child: const Text('Create Account'),
),
const SizedBox(
height: 10,
),
TextButton(
onPressed: () {
getIt<GoRouter>().replace('/login');
},
child: const Text('Back to Login'))
]));
}
@override
void dispose() {
usernameTextController.dispose();
passwordTextController.dispose();
super.dispose();
}
void formValuesUpdated() {
final usernameValidation = usernameValidator(usernameTextController.text);
final passwordValidation = passwordValidator(passwordTextController.text);
setState(() {
canCreateAccount =
usernameValidation == null && passwordValidation == null;
});
}
void checkUsernameAvailability() async {
final usernameText = usernameTextController.text;
// Set usernameAvailable to null if the username is invalid.
if (usernameValidator(usernameText) != null) {
setState(() => usernameAvailable = null);
return;
}
// Otherwise, there's a valid username, so check if it's available.
final available = await getUsernameAvailability(usernameText);
setState(() => usernameAvailable = available);
}
void createAccount() async {
print('Creating account...');
if (formKey.currentState!.validate()) {
setState(() => loading = true);
final credentials = LoginCredentials(
usernameTextController.text, passwordTextController.text);
try {
final token = await postRegister(credentials);
getIt<AuthenticationModel>().state =
Authenticated(token, credentials.username);
} catch (e) {
print(e);
} finally {
setState(() => loading = false);
}
}
}
}

View File

@ -0,0 +1,18 @@
import 'dart:async';
class Debouncer {
final Duration delay;
Timer? _timer;
Debouncer({this.delay = const Duration(milliseconds: 300)});
run(void Function() action) {
_timer?.cancel();
_timer = Timer(delay, action);
}
void dispose() {
_timer?.cancel();
_timer = null;
}
}

View File

@ -66,15 +66,60 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "4.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functional_listener:
dependency: transitive
description:
name: functional_listener
sha256: "026d1bd4f66367f11d9ec9f1f1ddb42b89e4484b356972c76d983266cf82f33f"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
get_it:
dependency: "direct main"
description:
name: get_it
sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1
url: "https://pub.dev"
source: hosted
version: "7.7.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: d380de0355788c5c784fe9f81b43fc833b903991c25ecc4e2a416a67faefa722
url: "https://pub.dev"
source: hosted
version: "14.2.2"
http:
dependency: "direct main"
description:
name: http
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
url: "https://pub.dev"
source: hosted
version: "1.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
leak_tracker:
dependency: transitive
description:
@ -103,10 +148,18 @@ packages:
dependency: transitive
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
matcher:
dependency: transitive
description:
@ -192,6 +245,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
vector_math:
dependency: transitive
description:
@ -208,6 +269,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "14.2.1"
watch_it:
dependency: "direct main"
description:
name: watch_it
sha256: a01a9e8292c040de82670f28f8a7d35315115a22f3674d2c4a8fd811fd1ac0ab
url: "https://pub.dev"
source: hosted
version: "1.4.2"
web:
dependency: transitive
description:
name: web
sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062
url: "https://pub.dev"
source: hosted
version: "1.0.0"
sdks:
dart: ">=3.4.4 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@ -35,6 +35,10 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
http: ^1.2.2
go_router: ^14.2.2
get_it: ^7.7.0
watch_it: ^1.4.2
dev_dependencies:
flutter_test:
@ -45,7 +49,7 @@ dev_dependencies:
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^3.0.0
flutter_lints: ^4.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec