Added registration page to app, username availability checking.
This commit is contained in:
parent
0362fe3323
commit
270dcc6020
|
@ -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.
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -10,23 +10,24 @@ import handy_httpd.handlers.filtered_handler;
|
|||
*/
|
||||
PathHandler mapApiHandlers() {
|
||||
/// The base path to all API endpoints.
|
||||
const API_PATH = "/api";
|
||||
const API_PATH = "/api";
|
||||
PathHandler h = new PathHandler();
|
||||
|
||||
// Generic, public endpoints:
|
||||
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
|
||||
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
|
||||
|
||||
|
||||
// Auth endpoints:
|
||||
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();
|
||||
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
||||
|
||||
import profile.api;
|
||||
|
||||
import profile.api;
|
||||
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
|
||||
a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile);
|
||||
/// URL path to a specific profile, with the :profile path parameter.
|
||||
|
@ -34,25 +35,26 @@ PathHandler mapApiHandlers() {
|
|||
a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
||||
|
||||
import account.api;
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||
import account.api;
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||
a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
||||
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
|
||||
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
|
||||
|
||||
// Protect all authenticated paths with a token filter.
|
||||
// Protect all authenticated paths with a token filter.
|
||||
import auth.service : TokenAuthenticationFilter, SECRET;
|
||||
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
|
||||
h.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(
|
||||
a,
|
||||
[tokenAuthenticationFilter]
|
||||
a,
|
||||
[tokenAuthenticationFilter]
|
||||
));
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
private void getStatus(ref HttpRequestContext ctx) {
|
||||
ctx.response.writeBodyString("online");
|
||||
ctx.response.writeBodyString("online");
|
||||
}
|
||||
|
||||
private void getOptions(ref HttpRequestContext ctx) {}
|
||||
private void getOptions(ref HttpRequestContext ctx) {
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
@ -26,27 +21,32 @@ void postLogin(ref HttpRequestContext ctx) {
|
|||
Optional!User optionalUser = userRepo.findByUsername(loginCredentials.username);
|
||||
if (optionalUser.isNull) {
|
||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||
return;
|
||||
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) {
|
||||
|
|
|
@ -14,6 +14,10 @@ struct TokenResponse {
|
|||
string token;
|
||||
}
|
||||
|
||||
struct UsernameAvailabilityResponse {
|
||||
bool available;
|
||||
}
|
||||
|
||||
struct RegistrationData {
|
||||
string username;
|
||||
string password;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
class FinnowApi {
|
||||
|
||||
}
|
|
@ -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.');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
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)
|
||||
if (!loginFailed)
|
||||
TextButton(onPressed: onLoginPressed, child: const Text('Login')),
|
||||
if (loginFailed) ...[
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {}, child: const Text('Forgot password?')),
|
||||
TextButton(onPressed: () {}, child: const Text('Create an Account'))
|
||||
onPressed: () {}, child: const Text('Forgot password?'))
|
||||
],
|
||||
TextButton(
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
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:',
|
||||
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(
|
||||
title: const Text('Finnow'),
|
||||
backgroundColor: Colors.grey,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => getIt<AuthenticationModel>().state =
|
||||
Unauthenticated(),
|
||||
child: const Text('Logout'))
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(),
|
||||
)
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue