365 lines
10 KiB
Dart
365 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'api.dart';
|
|
import 'format.dart';
|
|
import 'models.dart';
|
|
|
|
class HttpApi implements Api {
|
|
HttpApi({
|
|
required String baseUrl,
|
|
required SharedPreferences prefs,
|
|
http.Client? client,
|
|
this.timeout = const Duration(seconds: 20),
|
|
}) : _base = baseUrl.replaceAll(RegExp(r'/+$'), ''),
|
|
_prefs = prefs,
|
|
_client = client ?? http.Client();
|
|
|
|
static const _tokenKey = 'token';
|
|
static const _unreachable =
|
|
'Unable to reach the server. Check your connection and try again.';
|
|
|
|
final String _base;
|
|
final SharedPreferences _prefs;
|
|
final http.Client _client;
|
|
final Duration timeout;
|
|
final _ended = StreamController<void>.broadcast();
|
|
|
|
@override
|
|
DateTime get now => istWallClock(DateTime.now());
|
|
|
|
@override
|
|
Stream<void> get sessionEnded => _ended.stream;
|
|
|
|
// ---- transport ---------------------------------------------------------
|
|
|
|
Uri _uri(String path, [Map<String, String>? query]) {
|
|
final origin = _base.isEmpty ? Uri.base.origin : _base;
|
|
return Uri.parse('$origin/api$path').replace(queryParameters: query);
|
|
}
|
|
|
|
/// Sends a request and returns the decoded JSON body (null for 204).
|
|
/// Any failure becomes an [ApiException] whose message can be shown as is.
|
|
Future<Object?> _send(
|
|
String method,
|
|
String path, {
|
|
Map<String, String>? query,
|
|
Object? body,
|
|
bool signedIn = true,
|
|
}) async {
|
|
final request = http.Request(method, _uri(path, query));
|
|
final token = _prefs.getString(_tokenKey);
|
|
if (signedIn && token != null) {
|
|
request.headers['authorization'] = 'Bearer $token';
|
|
}
|
|
if (body != null) {
|
|
request.headers['content-type'] = 'application/json';
|
|
request.body = jsonEncode(body);
|
|
}
|
|
|
|
final http.Response response;
|
|
try {
|
|
response = await http.Response.fromStream(
|
|
await _client.send(request).timeout(timeout),
|
|
);
|
|
} on Exception {
|
|
throw ApiException(_unreachable);
|
|
}
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
if (response.body.isEmpty) return null;
|
|
try {
|
|
return jsonDecode(response.body);
|
|
} on FormatException {
|
|
throw ApiException(_unreachable);
|
|
}
|
|
}
|
|
if (response.statusCode == 401 && signedIn) _endSession();
|
|
throw ApiException(_errorMessage(response));
|
|
}
|
|
|
|
String _errorMessage(http.Response r) {
|
|
try {
|
|
final m = (jsonDecode(r.body) as Map<String, dynamic>)['message'];
|
|
if (m is String && m.isNotEmpty) return m;
|
|
} on Object {
|
|
// ignore: avoid_catching_errors
|
|
}
|
|
return r.statusCode >= 500
|
|
? 'Something went wrong on the server. Try again shortly.'
|
|
: 'The request could not be completed.';
|
|
}
|
|
|
|
void _endSession() {
|
|
_prefs.remove(_tokenKey);
|
|
_ended.add(null);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _object(
|
|
String method,
|
|
String path, {
|
|
Map<String, String>? query,
|
|
Object? body,
|
|
bool signedIn = true,
|
|
}) async =>
|
|
await _send(method, path, query: query, body: body, signedIn: signedIn)
|
|
as Map<String, dynamic>;
|
|
|
|
Future<List<Map<String, dynamic>>> _list(
|
|
String path,
|
|
String key, {
|
|
Map<String, String>? query,
|
|
}) async {
|
|
final json = await _object('GET', path, query: query);
|
|
return (json[key] as List).cast<Map<String, dynamic>>();
|
|
}
|
|
|
|
// ---- mapping -----------------------------------------------------------
|
|
|
|
AppUser _user(Map<String, dynamic> j) => AppUser(
|
|
id: j['id'] as int,
|
|
username: j['username'] as String,
|
|
phone: j['phone'] as String?,
|
|
isAdmin: j['isAdmin'] as bool,
|
|
active: j['active'] as bool,
|
|
shopIds: (j['shopIds'] as List).cast<int>(),
|
|
sendResults: j['sendResults'] as bool? ?? false,
|
|
);
|
|
|
|
Shop _shop(Map<String, dynamic> j) => Shop(
|
|
id: j['id'] as int,
|
|
code: j['code'] as String,
|
|
name: j['name'] as String,
|
|
active: j['active'] as bool,
|
|
);
|
|
|
|
DrawInfo _draw(Map<String, dynamic> j) => DrawInfo(
|
|
date: parseDate(j['date'] as String),
|
|
scheme: j['scheme'] as String?,
|
|
code: j['code'] as String?,
|
|
published: j['published'] as bool,
|
|
hasWin: j['hasWin'] as bool,
|
|
);
|
|
|
|
EndingQty _line(Map<String, dynamic> j) => EndingQty(
|
|
ending: j['ending'] as String,
|
|
qty: j['qty'] as int,
|
|
hits: [
|
|
for (final h in (j['hits'] as List).cast<Map<String, dynamic>>())
|
|
PrizeHit(tier: h['tier'] as int, amount: h['amount'] as int),
|
|
],
|
|
);
|
|
|
|
Order _order(Map<String, dynamic> j) => Order(
|
|
id: j['id'] as int,
|
|
shop: _shop(j['shop'] as Map<String, dynamic>),
|
|
drawDate: parseDate(j['drawDate'] as String),
|
|
time: istWallClock(DateTime.parse(j['createdAt'] as String)),
|
|
isPhoto: j['isPhoto'] as bool,
|
|
edited: j['edited'] as bool,
|
|
rawText: j['rawText'] as String?,
|
|
lines: [
|
|
for (final l in (j['lines'] as List).cast<Map<String, dynamic>>())
|
|
_line(l),
|
|
],
|
|
);
|
|
|
|
String _day(DateTime d) => isoDate(d);
|
|
|
|
// ---- auth --------------------------------------------------------------
|
|
|
|
@override
|
|
Future<DateTime> latestDrawDate() async =>
|
|
parseDate((await _object('GET', '/draws/latest'))['date'] as String);
|
|
|
|
@override
|
|
Future<AppUser> login(String username, String password) async {
|
|
final json = await _object(
|
|
'POST',
|
|
'/auth/login',
|
|
body: {'username': username.trim(), 'password': password},
|
|
signedIn: false,
|
|
);
|
|
await _prefs.setString(_tokenKey, json['token'] as String);
|
|
return _user(json['user'] as Map<String, dynamic>);
|
|
}
|
|
|
|
@override
|
|
Future<AppUser?> restore() async {
|
|
if (_prefs.getString(_tokenKey) == null) return null;
|
|
try {
|
|
return _user(await _object('GET', '/me'));
|
|
} on ApiException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void logout() => _prefs.remove(_tokenKey);
|
|
|
|
// ---- results -----------------------------------------------------------
|
|
|
|
@override
|
|
Future<List<DrawInfo>> month(int year, int month) async {
|
|
final text = '$year-${month.toString().padLeft(2, '0')}';
|
|
return [
|
|
for (final d in await _list('/draws', 'days', query: {'month': text}))
|
|
_draw(d),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Future<DrawResults> results(DateTime date) async {
|
|
final json = await _object('GET', '/draws/${_day(date)}/results');
|
|
return DrawResults(
|
|
draw: _draw(json['draw'] as Map<String, dynamic>),
|
|
shops: [
|
|
for (final s in (json['shops'] as List).cast<Map<String, dynamic>>())
|
|
ShopResult(
|
|
shop: _shop(s['shop'] as Map<String, dynamic>),
|
|
tickets: s['tickets'] as int,
|
|
orders: s['orders'] as int,
|
|
winTickets: s['winTickets'] as int,
|
|
prize: s['prize'] as int,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ---- shops -------------------------------------------------------------
|
|
|
|
@override
|
|
Future<List<ShopDay>> shops(DateTime date) async => [
|
|
for (final s in await _list('/shops', 'shops', query: {'date': _day(date)}))
|
|
ShopDay(
|
|
shop: _shop(s['shop'] as Map<String, dynamic>),
|
|
tickets: s['tickets'] as int,
|
|
orders: s['orders'] as int,
|
|
endings: s['endings'] as int,
|
|
),
|
|
];
|
|
|
|
@override
|
|
Future<ShopDetail> shopDetail(int shopId, DateTime date) async {
|
|
final json = await _object('GET', '/shops/$shopId/draws/${_day(date)}');
|
|
return ShopDetail(
|
|
shop: _shop(json['shop'] as Map<String, dynamic>),
|
|
draw: _draw(json['draw'] as Map<String, dynamic>),
|
|
endings: [
|
|
for (final e in (json['endings'] as List).cast<Map<String, dynamic>>())
|
|
_line(e),
|
|
],
|
|
orders: [
|
|
for (final o in (json['orders'] as List).cast<Map<String, dynamic>>())
|
|
_order(o),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<Shop>> activeShops() async => [
|
|
for (final s in await _list('/shops/options', 'shops')) _shop(s),
|
|
];
|
|
|
|
@override
|
|
Future<Shop> createShop(String name, String code) async => _shop(
|
|
await _object('POST', '/shops', body: {'name': name, 'code': code}),
|
|
);
|
|
|
|
@override
|
|
Future<void> renameShop(int shopId, String name) =>
|
|
_send('PATCH', '/shops/$shopId', body: {'name': name});
|
|
|
|
@override
|
|
Future<void> setShopActive(int shopId, bool active) =>
|
|
_send('PATCH', '/shops/$shopId', body: {'active': active});
|
|
|
|
// ---- orders ------------------------------------------------------------
|
|
|
|
@override
|
|
Future<List<Order>> orders(DateTime date) async => [
|
|
for (final o in await _list(
|
|
'/orders',
|
|
'orders',
|
|
query: {'date': _day(date)},
|
|
))
|
|
_order(o),
|
|
];
|
|
|
|
@override
|
|
Future<Order> order(int orderId) async =>
|
|
_order(await _object('GET', '/orders/$orderId'));
|
|
|
|
@override
|
|
Future<void> setLineQty(int orderId, String ending, int qty) =>
|
|
_send('PUT', '/orders/$orderId/lines/$ending', body: {'qty': qty});
|
|
|
|
@override
|
|
Future<void> removeLine(int orderId, String ending) =>
|
|
_send('DELETE', '/orders/$orderId/lines/$ending');
|
|
|
|
@override
|
|
Future<void> cancelOrder(int orderId) => _send('DELETE', '/orders/$orderId');
|
|
|
|
// ---- users -------------------------------------------------------------
|
|
|
|
@override
|
|
Future<List<AppUser>> users() async => [
|
|
for (final u in await _list('/users', 'users')) _user(u),
|
|
];
|
|
|
|
@override
|
|
Future<AppUser> user(int id) async =>
|
|
_user(await _object('GET', '/users/$id'));
|
|
|
|
@override
|
|
Future<AppUser> createUser({
|
|
required String username,
|
|
String? phone,
|
|
required String password,
|
|
required List<int> shopIds,
|
|
}) async => _user(
|
|
await _object(
|
|
'POST',
|
|
'/users',
|
|
body: {
|
|
'username': username,
|
|
'phone': phone,
|
|
'password': password,
|
|
'shopIds': shopIds,
|
|
},
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> updateUser(
|
|
int id, {
|
|
String? username,
|
|
String? phone,
|
|
bool clearPhone = false,
|
|
String? password,
|
|
bool? active,
|
|
bool? sendResults,
|
|
}) => _send(
|
|
'PATCH',
|
|
'/users/$id',
|
|
body: {
|
|
'username': ?username,
|
|
if (clearPhone) 'phone': null else 'phone': ?phone,
|
|
'password': ?password,
|
|
'active': ?active,
|
|
'sendResults': ?sendResults,
|
|
},
|
|
);
|
|
|
|
@override
|
|
Future<void> setUserShops(int id, List<int> shopIds) =>
|
|
_send('PUT', '/users/$id/shops', body: {'shopIds': shopIds});
|
|
|
|
@override
|
|
Future<void> deleteUser(int id) => _send('DELETE', '/users/$id');
|
|
}
|