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.broadcast(); @override DateTime get now => istWallClock(DateTime.now()); @override Stream get sessionEnded => _ended.stream; // ---- transport --------------------------------------------------------- Uri _uri(String path, [Map? 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 _send( String method, String path, { Map? 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)['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> _object( String method, String path, { Map? query, Object? body, bool signedIn = true, }) async => await _send(method, path, query: query, body: body, signedIn: signedIn) as Map; Future>> _list( String path, String key, { Map? query, }) async { final json = await _object('GET', path, query: query); return (json[key] as List).cast>(); } // ---- mapping ----------------------------------------------------------- AppUser _user(Map 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(), sendResults: j['sendResults'] as bool? ?? false, ); Shop _shop(Map 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 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 j) => EndingQty( ending: j['ending'] as String, qty: j['qty'] as int, hits: [ for (final h in (j['hits'] as List).cast>()) PrizeHit(tier: h['tier'] as int, amount: h['amount'] as int), ], ); Order _order(Map j) => Order( id: j['id'] as int, shop: _shop(j['shop'] as Map), 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>()) _line(l), ], ); String _day(DateTime d) => isoDate(d); // ---- auth -------------------------------------------------------------- @override Future latestDrawDate() async => parseDate((await _object('GET', '/draws/latest'))['date'] as String); @override Future 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); } @override Future 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> 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 results(DateTime date) async { final json = await _object('GET', '/draws/${_day(date)}/results'); return DrawResults( draw: _draw(json['draw'] as Map), shops: [ for (final s in (json['shops'] as List).cast>()) ShopResult( shop: _shop(s['shop'] as Map), tickets: s['tickets'] as int, orders: s['orders'] as int, winTickets: s['winTickets'] as int, prize: s['prize'] as int, ), ], ); } // ---- shops ------------------------------------------------------------- @override Future> shops(DateTime date) async => [ for (final s in await _list('/shops', 'shops', query: {'date': _day(date)})) ShopDay( shop: _shop(s['shop'] as Map), tickets: s['tickets'] as int, orders: s['orders'] as int, endings: s['endings'] as int, ), ]; @override Future shopDetail(int shopId, DateTime date) async { final json = await _object('GET', '/shops/$shopId/draws/${_day(date)}'); return ShopDetail( shop: _shop(json['shop'] as Map), draw: _draw(json['draw'] as Map), endings: [ for (final e in (json['endings'] as List).cast>()) _line(e), ], orders: [ for (final o in (json['orders'] as List).cast>()) _order(o), ], ); } @override Future> activeShops() async => [ for (final s in await _list('/shops/options', 'shops')) _shop(s), ]; @override Future createShop(String name, String code) async => _shop( await _object('POST', '/shops', body: {'name': name, 'code': code}), ); @override Future renameShop(int shopId, String name) => _send('PATCH', '/shops/$shopId', body: {'name': name}); @override Future setShopActive(int shopId, bool active) => _send('PATCH', '/shops/$shopId', body: {'active': active}); // ---- orders ------------------------------------------------------------ @override Future> orders(DateTime date) async => [ for (final o in await _list( '/orders', 'orders', query: {'date': _day(date)}, )) _order(o), ]; @override Future order(int orderId) async => _order(await _object('GET', '/orders/$orderId')); @override Future setLineQty(int orderId, String ending, int qty) => _send('PUT', '/orders/$orderId/lines/$ending', body: {'qty': qty}); @override Future removeLine(int orderId, String ending) => _send('DELETE', '/orders/$orderId/lines/$ending'); @override Future cancelOrder(int orderId) => _send('DELETE', '/orders/$orderId'); // ---- users ------------------------------------------------------------- @override Future> users() async => [ for (final u in await _list('/users', 'users')) _user(u), ]; @override Future user(int id) async => _user(await _object('GET', '/users/$id')); @override Future createUser({ required String username, String? phone, required String password, required List shopIds, }) async => _user( await _object( 'POST', '/users', body: { 'username': username, 'phone': phone, 'password': password, 'shopIds': shopIds, }, ), ); @override Future 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 setUserShops(int id, List shopIds) => _send('PUT', '/users/$id/shops', body: {'shopIds': shopIds}); @override Future deleteUser(int id) => _send('DELETE', '/users/$id'); }