commit 45fcdcebfbca3dee24aef11fd0e32c087b4fe2e2 Author: Aswin Biju Date: Wed Nov 4 23:21:50 2026 +0530 Initial commit Add SRS app: results, shops, orders and users, wired to srs-api Flutter web/PWA for the lottery agency, built on Riverpod and go_router, talking to srs-api over HTTP (HttpApi, token kept in SharedPreferences). - Results, Shops, Orders and Users tabs (Users for admins only), with phone, tablet and desktop layouts and shared widgets and theme tokens - Users: list with search and Active/Inactive filters; user detail with shops, sign-in details, and an Access card - WhatsApp results switch on each user (sendResults on PATCH /api/users/:id). Shows the WhatsApp logo (Simple Icons, CC0, drawn with flutter_svg), green only when results will really go out. Can't be turned on without a phone number ("Add a phone number first" opens the phone sheet), and is paused while the user is inactive. The users list marks people who get results with the logo. - Each Access switch has a one-line description; the explanation paragraphs are gone - Remove demo mode: the app always uses the real AP test/support and is used only by tests. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6c18e9 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +API_BASE_URL=http://127.0.0.1:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a1dd8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Local settings (copy .env.example to .env) +.env + +# Widget Preview related +.widget_preview/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..1d961ec --- /dev/null +++ b/.metadata @@ -0,0 +1,18 @@ +version: + revision: "c197ca5199e9ee5661e865596c9c07294dfab51c" + channel: "stable" + +project_type: app + +migration: + platforms: + - platform: root + create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + - platform: web + create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c + + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b614b09 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "SRS (local API)", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "deviceId": "chrome", + "args": ["--web-port", "5000", "--dart-define-from-file=.env"] + } + ] +} diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..c4084c0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,24 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/assets/fonts/Inter-Bold.otf b/assets/fonts/Inter-Bold.otf new file mode 100644 index 0000000..58a3807 Binary files /dev/null and b/assets/fonts/Inter-Bold.otf differ diff --git a/assets/fonts/Inter-Medium.otf b/assets/fonts/Inter-Medium.otf new file mode 100644 index 0000000..f44f89a Binary files /dev/null and b/assets/fonts/Inter-Medium.otf differ diff --git a/assets/fonts/Inter-Regular.otf b/assets/fonts/Inter-Regular.otf new file mode 100644 index 0000000..2d0bd1d Binary files /dev/null and b/assets/fonts/Inter-Regular.otf differ diff --git a/assets/fonts/Inter-SemiBold.otf b/assets/fonts/Inter-SemiBold.otf new file mode 100644 index 0000000..52c8455 Binary files /dev/null and b/assets/fonts/Inter-SemiBold.otf differ diff --git a/assets/icons/whatsapp.svg b/assets/icons/whatsapp.svg new file mode 100644 index 0000000..2a6c69f --- /dev/null +++ b/assets/icons/whatsapp.svg @@ -0,0 +1 @@ +WhatsApp \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..7d507c4 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'core/providers.dart'; +import 'core/theme.dart'; +import 'features/auth/login_screen.dart'; +import 'features/orders/order_detail_screen.dart'; +import 'features/orders/orders_screen.dart'; +import 'features/results/results_screen.dart'; +import 'features/shops/shop_detail_screen.dart'; +import 'features/shops/shops_screen.dart'; +import 'features/shops/tickets_screen.dart'; +import 'features/users/user_detail_screen.dart'; +import 'features/users/user_form_screen.dart'; +import 'features/users/users_screen.dart'; +import 'widgets/main_shell.dart'; + +final routerProvider = Provider((ref) { + final refresh = ValueNotifier(0); + ref.listen(sessionProvider, (_, _) => refresh.value++); + ref.onDispose(refresh.dispose); + + Page tab(GoRouterState s, Widget w) => + NoTransitionPage(key: s.pageKey, child: w); + + return GoRouter( + initialLocation: '/results', + refreshListenable: refresh, + redirect: (context, state) { + final user = ref.read(sessionProvider); + final loc = state.matchedLocation; + if (user == null) return loc == '/login' ? null : '/login'; + if (loc == '/login') return '/results'; + if (loc.startsWith('/users') && !user.isAdmin) return '/results'; + return null; + }, + routes: [ + GoRoute(path: '/login', builder: (_, _) => const LoginScreen()), + // Every signed-in screen lives in the shell, so the sidebar stays in + // place on wide windows while detail screens open beside it. + ShellRoute( + builder: (_, s, child) => + MainShell(location: s.matchedLocation, child: child), + routes: [ + GoRoute( + path: '/results', + pageBuilder: (_, s) => tab(s, const ResultsScreen()), + ), + GoRoute( + path: '/shops', + pageBuilder: (_, s) => tab(s, const ShopsScreen()), + routes: [ + GoRoute( + path: ':id', + builder: (_, s) => ShopDetailScreen(shopId: _id(s)), + routes: [ + GoRoute( + path: 'tickets', + builder: (_, s) => TicketsScreen(shopId: _id(s)), + ), + ], + ), + ], + ), + GoRoute( + path: '/orders', + pageBuilder: (_, s) => tab(s, const OrdersScreen()), + routes: [ + GoRoute( + path: ':id', + builder: (_, s) => OrderDetailScreen(orderId: _id(s)), + ), + ], + ), + GoRoute( + path: '/users', + pageBuilder: (_, s) => tab(s, const UsersScreen()), + routes: [ + GoRoute(path: 'new', builder: (_, _) => const UserFormScreen()), + GoRoute( + path: ':id', + builder: (_, s) => UserDetailScreen(userId: _id(s)), + ), + ], + ), + ], + ), + ], + ); +}); + +int _id(GoRouterState s) => int.parse(s.pathParameters['id']!); + +class SrsApp extends ConsumerWidget { + const SrsApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp.router( + title: 'SRS', + debugShowCheckedModeBanner: false, + theme: buildTheme(), + routerConfig: ref.watch(routerProvider), + ); + } +} diff --git a/lib/core/api.dart b/lib/core/api.dart new file mode 100644 index 0000000..e526210 --- /dev/null +++ b/lib/core/api.dart @@ -0,0 +1,53 @@ +import 'models.dart'; + +class ApiException implements Exception { + ApiException(this.message); + final String message; + @override + String toString() => message; +} + +abstract class Api { + DateTime get now; + Stream get sessionEnded; + Future latestDrawDate(); + Future login(String username, String password); + Future restore(); + void logout(); + + Future> month(int year, int month); + Future results(DateTime date); + + Future> shops(DateTime date); + Future shopDetail(int shopId, DateTime date); + Future> activeShops(); + Future createShop(String name, String code); + Future renameShop(int shopId, String name); + Future setShopActive(int shopId, bool active); + + Future> orders(DateTime date); + Future order(int orderId); + Future setLineQty(int orderId, String ending, int qty); + Future removeLine(int orderId, String ending); + Future cancelOrder(int orderId); + + Future> users(); + Future user(int id); + Future createUser({ + required String username, + String? phone, + required String password, + required List shopIds, + }); + Future updateUser( + int id, { + String? username, + String? phone, + bool clearPhone = false, + String? password, + bool? active, + bool? sendResults, + }); + Future setUserShops(int id, List shopIds); + Future deleteUser(int id); +} diff --git a/lib/core/config.dart b/lib/core/config.dart new file mode 100644 index 0000000..9a2effa --- /dev/null +++ b/lib/core/config.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; + +class AppConfig { + const AppConfig._(); + static const apiBaseUrl = String.fromEnvironment('API_BASE_URL'); + static bool get unconfigured => kDebugMode && apiBaseUrl.isEmpty; + + static const setupHint = + 'No API_BASE_URL set. For local development run with ' + '--dart-define-from-file=.env (copy .env.example to .env first).'; +} diff --git a/lib/core/format.dart b/lib/core/format.dart new file mode 100644 index 0000000..e15745d --- /dev/null +++ b/lib/core/format.dart @@ -0,0 +1,117 @@ +const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const _monthsLong = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +String inr(int n) { + final neg = n < 0; + final s = n.abs().toString(); + if (s.length <= 3) return '${neg ? '-' : ''}$s'; + final last = s.substring(s.length - 3); + var rest = s.substring(0, s.length - 3); + final parts = []; + while (rest.length > 2) { + parts.insert(0, rest.substring(rest.length - 2)); + rest = rest.substring(0, rest.length - 2); + } + if (rest.isNotEmpty) parts.insert(0, rest); + return '${neg ? '-' : ''}${parts.join(',')},$last'; +} + +String rupees(int n) => '₹${inr(n)}'; + +DateTime dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); +bool sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; +DateTime addDays(DateTime d, int n) => DateTime(d.year, d.month, d.day + n); + +/// Fri, 16 Oct 2026 +String dateLong(DateTime d) => + '${_weekdays[d.weekday - 1]}, ${d.day} ${_months[d.month - 1]} ${d.year}'; + +/// Fri, 16 Oct +String dateShort(DateTime d) => + '${_weekdays[d.weekday - 1]}, ${d.day} ${_months[d.month - 1]}'; + +/// October 2026 +String monthYear(DateTime d) => '${_monthsLong[d.month - 1]} ${d.year}'; + +/// 12:41 PM +String time12(DateTime d) { + final h = d.hour % 12 == 0 ? 12 : d.hour % 12; + final m = d.minute.toString().padLeft(2, '0'); + return '$h:$m ${d.hour >= 12 ? 'PM' : 'AM'}'; +} + +String isoDate(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +String plural(int n, String one, [String? many]) => + '${inr(n)} ${n == 1 ? one : (many ?? '${one}s')}'; + +/// 919000012345 -> +91 90000 12345 +String fmtPhone(String? digits) { + if (digits == null || digits.isEmpty) return ''; + if (digits.length == 12 && digits.startsWith('91')) { + return '+91 ${digits.substring(2, 7)} ${digits.substring(7)}'; + } + return '+$digits'; +} + +String initials(String name) { + final parts = name.split(RegExp(r'[._\- ]+')).where((p) => p.isNotEmpty); + final list = parts.toList(); + if (list.isEmpty) return '?'; + if (list.length == 1) { + final w = list.first; + return w.substring(0, w.length >= 2 ? 2 : 1).toUpperCase(); + } + return (list[0][0] + list[1][0]).toUpperCase(); +} + +String tierLabel(int tier) => '${tier}th prize'; + +/// "5th prize", or "5th + 8th prize" for an ending in two tiers. +String tiersLabel(List tiers) { + final sorted = [...tiers]..sort(); + if (sorted.length < 2) return sorted.isEmpty ? '' : tierLabel(sorted.first); + return '${sorted.map((t) => '${t}th').join(' + ')} prize'; +} + +/// A server instant (UTC) as the Indian wall-clock time the screens show. +DateTime istWallClock(DateTime instant) { + final t = instant.toUtc().add(const Duration(hours: 5, minutes: 30)); + return DateTime(t.year, t.month, t.day, t.hour, t.minute, t.second); +} + +/// Parses a YYYY-MM-DD calendar date. +DateTime parseDate(String iso) => DateTime( + int.parse(iso.substring(0, 4)), + int.parse(iso.substring(5, 7)), + int.parse(iso.substring(8, 10)), +); diff --git a/lib/core/http_api.dart b/lib/core/http_api.dart new file mode 100644 index 0000000..8fd2001 --- /dev/null +++ b/lib/core/http_api.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api.dart'; +import 'config.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 String get _unreachable => AppConfig.unconfigured + ? 'Unable to reach the server. ${AppConfig.setupHint}' + : '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) { + return response.body.isEmpty ? null : jsonDecode(response.body); + } + 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'); +} diff --git a/lib/core/models.dart b/lib/core/models.dart new file mode 100644 index 0000000..e8d48e7 --- /dev/null +++ b/lib/core/models.dart @@ -0,0 +1,173 @@ +import 'format.dart'; + +class AppUser { + const AppUser({ + required this.id, + required this.username, + required this.isAdmin, + required this.active, + required this.shopIds, + this.phone, + this.sendResults = false, + }); + + final int id; + final String username; + final String? phone; + final bool isAdmin; + final bool active; + final List shopIds; + final bool sendResults; + + bool get getsResults => sendResults && active && phone != null; +} + +class Shop { + const Shop({ + required this.id, + required this.code, + required this.name, + this.active = true, + }); + + final int id; + final String code; + final String name; + final bool active; + + Shop copyWith({String? name, bool? active}) => Shop( + id: id, + code: code, + name: name ?? this.name, + active: active ?? this.active, + ); +} + +class DrawInfo { + const DrawInfo({ + required this.date, + required this.published, + this.scheme, + this.code, + this.hasWin = false, + }); + + final DateTime date; + final String? scheme; + final String? code; + final bool published; + final bool hasWin; + String get label => published ? '$scheme · $code' : 'No result yet'; +} +class PrizeHit { + const PrizeHit({required this.tier, required this.amount}); + + final int tier; + final int amount; +} + +class EndingQty { + const EndingQty({ + required this.ending, + required this.qty, + this.hits = const [], + }); + + final String ending; + final int qty; + final List hits; + bool get wins => hits.isNotEmpty; + int get perTicket => hits.fold(0, (a, h) => a + h.amount); + int get prize => qty * perTicket; + String get tierText => tiersLabel([for (final h in hits) h.tier]); +} + +class Order { + const Order({ + required this.id, + required this.shop, + required this.drawDate, + required this.time, + required this.isPhoto, + required this.edited, + required this.lines, + this.rawText, + }); + + final int id; + final Shop shop; + final DateTime drawDate; + final DateTime time; + final bool isPhoto; + final bool edited; + final String? rawText; + final List lines; + + int get tickets => lines.fold(0, (a, l) => a + l.qty); + int get prize => lines.fold(0, (a, l) => a + l.prize); + int get winTickets => lines.where((l) => l.wins).fold(0, (a, l) => a + l.qty); +} + +class ShopResult { + const ShopResult({ + required this.shop, + required this.tickets, + required this.orders, + required this.winTickets, + required this.prize, + }); + + final Shop shop; + final int tickets; + final int orders; + final int winTickets; + final int prize; +} + +class DrawResults { + const DrawResults({required this.draw, required this.shops}); + + final DrawInfo draw; + final List shops; + + int get totalPrize => shops.fold(0, (a, s) => a + s.prize); + int get winningTickets => shops.fold(0, (a, s) => a + s.winTickets); + int get totalTickets => shops.fold(0, (a, s) => a + s.tickets); + int get orders => shops.fold(0, (a, s) => a + s.orders); + int get winningShops => shops.where((s) => s.prize > 0).length; + ShopResult? get topShop => + shops.isNotEmpty && shops.first.prize > 0 ? shops.first : null; +} + +class ShopDay { + const ShopDay({ + required this.shop, + required this.tickets, + required this.orders, + required this.endings, + }); + + final Shop shop; + final int tickets; + final int orders; + final int endings; +} + +class ShopDetail { + const ShopDetail({ + required this.shop, + required this.draw, + required this.endings, + required this.orders, + }); + + final Shop shop; + final DrawInfo draw; + final List endings; + final List orders; + + int get tickets => endings.fold(0, (a, e) => a + e.qty); + List get wins => endings.where((e) => e.wins).toList(); + int get winTickets => wins.fold(0, (a, e) => a + e.qty); + int get prize => wins.fold(0, (a, e) => a + e.prize); +} diff --git a/lib/core/providers.dart b/lib/core/providers.dart new file mode 100644 index 0000000..2ba2de1 --- /dev/null +++ b/lib/core/providers.dart @@ -0,0 +1,122 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api.dart'; +import 'format.dart'; +import 'models.dart'; + +final apiProvider = Provider((ref) => throw UnimplementedError()); + +final startDateProvider = Provider( + (ref) => throw UnimplementedError(), +); + +class DataVersion extends Notifier { + @override + int build() => 0; + void bump() => state++; +} + +final dataVersionProvider = NotifierProvider(DataVersion.new); + +class SessionNotifier extends Notifier { + SessionNotifier(this.initial); + final AppUser? initial; + + @override + AppUser? build() { + final sub = ref.read(apiProvider).sessionEnded.listen((_) => _end()); + ref.onDispose(sub.cancel); + return initial; + } + + Future login(String username, String password) async { + final api = ref.read(apiProvider); + final u = await api.login(username, password); + state = u; + try { + ref.read(selectedDateProvider.notifier).set(await api.latestDrawDate()); + } on ApiException {// ignore: empty_catches + } + } + + Future logout() async { + ref.read(apiProvider).logout(); + _end(); + } + + void _end() { + state = null; + ref.invalidate(selectedDateProvider); + } +} + +final sessionProvider = NotifierProvider( + () => SessionNotifier(null), +); + +class SelectedDate extends Notifier { + @override + DateTime build() => ref.read(startDateProvider); + void set(DateTime d) => state = dateOnly(d); +} + +final selectedDateProvider = NotifierProvider( + SelectedDate.new, +); + +final resultsProvider = FutureProvider.family((ref, d) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).results(d); +}); + +final shopsProvider = FutureProvider.family, DateTime>((ref, d) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).shops(d); +}); + +final shopDetailProvider = FutureProvider.family(( + ref, + k, +) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).shopDetail(k.$1, k.$2); +}); + +final ordersProvider = FutureProvider.family, DateTime>((ref, d) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).orders(d); +}); + +final orderProvider = FutureProvider.family((ref, id) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).order(id); +}); + +final monthProvider = FutureProvider.family, (int, int)>(( + ref, + k, +) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).month(k.$1, k.$2); +}); + +final activeShopsProvider = FutureProvider>((ref) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).activeShops(); +}); + +final usersProvider = FutureProvider>((ref) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).users(); +}); + +final userProvider = FutureProvider.family((ref, id) { + ref.watch(dataVersionProvider); + return ref.read(apiProvider).user(id); +}); + +Future mutate(WidgetRef ref, Future Function() fn) async { + final r = await fn(); + ref.read(dataVersionProvider.notifier).bump(); + return r; +} diff --git a/lib/core/theme.dart b/lib/core/theme.dart new file mode 100644 index 0000000..f328a1b --- /dev/null +++ b/lib/core/theme.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; + +class C { + static const bg = Color(0xFFF4F2EC); + static const outer = Color(0xFFE7E4DB); + static const ink = Color(0xFF1F2A2E); + static const muted = Color(0xFF5E6C71); // 4.9:1 on bg, 5.4:1 on white + static const navy = Color(0xFF1B2A49); + static const navyDeep = Color(0xFF101B33); + static const chip = Color(0xFFE1E6F0); + static const green = Color(0xFF2F7656); // 5.5:1 on white, 4.7:1 on greenTint + static const greenTint = Color(0xFFE3F1EA); + static const gold = Color(0xFFD4A64A); + static const goldTint = Color(0xFFF7EBD0); + static const goldText = Color(0xFF735614); // 5.4:1 on goldTint + static const border = Color(0xFFE6E0D4); + static const border2 = Color(0xFFC9CED8); + static const placeholder = Color(0xFF7A8689); // hints and chevrons, 3.8:1 + static const softOnNavy = Color(0xFFB8C2DA); + static const red = Color(0xFFB04A37); // 5.4:1 on white + static const redDeep = Color(0xFF953D2C); + static const redTint = Color(0xFFFBE9E5); + static const white = Colors.white; + static const hover = Color(0xFFF8F7F3); + static const focus = Color(0xFF3D5A99); + + // Overlays, shadows and disabled fills + static const onNavy5 = Color(0x0DFFFFFF); + static const onNavy8 = Color(0x14FFFFFF); + static const onNavy20 = Color(0x33FFFFFF); + static const goldSoft = Color(0x26D4A64A); + static const goldGlow = Color(0x55D4A64A); + static const shadowNavy = Color(0x331B2A49); + static const shadowNav = Color(0x14101B33); + static const disabled = Color(0xFFF0EEE8); + static const fieldDisabled = Color(0xFFEFEDE6); +} + +/// Width breakpoints, measured on the window, not the device. +class Bp { + static const medium = 600.0; // rail navigation, wider gutters + static const expanded = 1024.0; // full sidebar + static const pageMax = 1120.0; // widest content column + static const readMax = 760.0; // detail and form screens +} + +/// Horizontal padding that keeps content in a centred column of [max] width. +double pageGutter(double width, [double max = Bp.pageMax]) { + if (width < Bp.medium) return S.s20; + final side = (width - max) / 2; + return side > S.s32 ? side : S.s32; +} + +/// Spacing scale (logical pixels). +class S { + static const s2 = 2.0; + static const s4 = 4.0; + static const s6 = 6.0; + static const s8 = 8.0; + static const s10 = 10.0; + static const s12 = 12.0; + static const s14 = 14.0; + static const s16 = 16.0; + static const s18 = 18.0; + static const s20 = 20.0; + static const s22 = 22.0; + static const s24 = 24.0; + static const s28 = 28.0; + static const s32 = 32.0; + static const s36 = 36.0; +} + +/// Corner radii. +class R { + static const sm = 8.0; // checkbox, handles + static const md = 12.0; // chips, tiles + static const input = 14.0; // fields, small cards + static const button = 16.0; + static const card = 18.0; + static const dialog = 24.0; + static const hero = 28.0; // hero cards, sheets + static const pill = 99.0; +} + +TextStyle ts( + double size, { + FontWeight w = FontWeight.w500, + Color c = C.ink, + double? h, + double? ls, +}) => TextStyle( + fontFamily: 'Inter', + fontSize: size, + fontWeight: w, + color: c, + height: h, + letterSpacing: ls, + // Tabular figures keep ticket counts, endings and rupee amounts aligned. + fontFeatures: const [FontFeature.tabularFigures()], +); + +const w4 = FontWeight.w400; +const w5 = FontWeight.w500; +const w6 = FontWeight.w600; +const w7 = FontWeight.w700; + +ThemeData buildTheme() { + final scheme = + ColorScheme.fromSeed( + seedColor: C.navy, + brightness: Brightness.light, + ).copyWith( + primary: C.navy, + onPrimary: Colors.white, + surface: C.bg, + error: C.red, + ); + return ThemeData( + useMaterial3: true, + fontFamily: 'Inter', + colorScheme: scheme, + scaffoldBackgroundColor: C.bg, + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + hoverColor: C.hover, + focusColor: C.chip, + tooltipTheme: TooltipThemeData( + waitDuration: const Duration(milliseconds: 500), + textStyle: ts(12, w: w5, c: Colors.white), + decoration: BoxDecoration( + color: C.navyDeep, + borderRadius: BorderRadius.circular(R.sm), + ), + ), + textSelectionTheme: const TextSelectionThemeData(cursorColor: C.navy), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: C.navyDeep, + contentTextStyle: ts(13, w: w6, c: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(R.input), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(R.dialog), + ), + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(R.hero)), + ), + ), + ); +} diff --git a/lib/features/auth/login_screen.dart b/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..298b7a2 --- /dev/null +++ b/lib/features/auth/login_screen.dart @@ -0,0 +1,246 @@ + import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _user = TextEditingController(); + final _pass = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _user.dispose(); + _pass.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy) return; + if (_user.text.trim().isEmpty || _pass.text.isEmpty) { + setState(() => _error = 'Enter your username and password'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + await ref.read(sessionProvider.notifier).login(_user.text, _pass.text); + } on ApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'Unable to sign in. Please try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: C.navy, + body: LayoutBuilder( + builder: (context, c) { + final landscape = c.maxWidth > c.maxHeight && c.maxWidth >= 600; + if (c.maxWidth >= 900 || landscape) { + return Row( + children: [ + Expanded(child: _BrandPanel(large: c.maxHeight >= 560)), + Container( + width: c.maxWidth >= 1280 + ? 560 + : (c.maxWidth >= 900 ? 480 : 420), + color: C.bg, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(S.s36), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: _form(), + ), + ), + ), + ), + ], + ); + } + final hero = (c.maxHeight * 0.36).clamp(230.0, 340.0); + return Column( + children: [ + SizedBox( + height: hero, + width: double.infinity, + child: const _BrandPanel(large: false), + ), + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: C.bg, + borderRadius: BorderRadius.vertical( + top: Radius.circular(R.hero), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + S.s24, + S.s28, + S.s24, + S.s24, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: _form(), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _form() => AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Welcome back', style: ts(24, w: w7, ls: -0.3)), + const SizedBox(height: S.s4), + Text('Sign in to continue', style: ts(14, c: C.muted)), + const SizedBox(height: S.s24), + SrsField( + controller: _user, + label: 'Username', + hint: 'Enter username', + error: _error != null, + enabled: !_busy, + autofocus: true, + autofill: const [AutofillHints.username], + onChanged: (_) => setState(() => _error = null), + ), + const SizedBox(height: S.s16), + SrsField( + controller: _pass, + label: 'Password', + hint: 'Enter password', + password: true, + error: _error != null, + enabled: !_busy, + autofill: const [AutofillHints.password], + onChanged: (_) => setState(() => _error = null), + onSubmitted: (_) => _submit(), + ), + if (_error != null) ...[ + const SizedBox(height: S.s12), + ErrorLine(_error!), + ], + const SizedBox(height: S.s22), + BigButton('Sign in', loading: _busy, onPressed: _submit), + const SizedBox(height: S.s18), + Center( + child: Text( + 'Forgot your password? Contact your administrator.', + textAlign: TextAlign.center, + style: ts(13, c: C.muted), + ), + ), + ], + ), + ); +} + +class _BrandPanel extends StatelessWidget { + const _BrandPanel({required this.large}); + final bool large; + + @override + Widget build(BuildContext context) { + final coin = large ? 112.0 : 84.0; + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [C.navy, C.navyDeep], + ), + ), + child: Stack( + children: [ + Positioned( + right: large ? -120 : -50, + top: large ? -80 : -30, + child: _ring(large ? 420 : 190, C.onNavy8), + ), + Positioned( + left: large ? -90 : -40, + bottom: large ? -60 : 10, + child: _ring(large ? 300 : 120, C.onNavy5), + ), + SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: coin, + height: coin, + decoration: const BoxDecoration( + color: C.gold, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: C.goldGlow, + blurRadius: 30, + offset: Offset(0, 10), + ), + ], + ), + child: Icon( + Icons.confirmation_number_rounded, + color: C.navy, + size: coin * 0.48, + ), + ), + SizedBox(height: large ? S.s24 : S.s18), + Text( + 'SRS', + style: ts(large ? 44 : 34, w: w7, c: Colors.white, ls: 2), + ), + const SizedBox(height: S.s6), + Text( + 'Your sequences, at a glance', + style: ts(large ? 16 : 14, c: C.softOnNavy), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _ring(double size, Color color) => Container( + width: size, + height: size, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); +} diff --git a/lib/features/orders/order_card.dart b/lib/features/orders/order_card.dart new file mode 100644 index 0000000..b2b9f3e --- /dev/null +++ b/lib/features/orders/order_card.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +class OrderCard extends StatelessWidget { + const OrderCard({super.key, required this.order, required this.onTap}); + final Order order; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final o = order; + return SrsCard( + onTap: onTap, + padding: const EdgeInsets.all(S.s14), + child: Row( + children: [ + CodeTile(o.shop.code), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + o.shop.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6), + ), + ), + if (o.edited) ...[ + const SizedBox(width: S.s8), + const Pill('Edited', bg: C.goldTint, fg: C.goldText), + ], + ], + ), + const SizedBox(height: S.s4), + Row( + children: [ + Icon( + o.isPhoto + ? Icons.photo_camera_outlined + : Icons.chat_bubble_outline_rounded, + size: 13, + color: C.muted, + ), + const SizedBox(width: S.s6), + Flexible( + child: Text( + '${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(12, c: C.muted), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: S.s8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(inr(o.tickets), style: ts(18, w: w7)), + Text('tickets', style: ts(11, c: C.muted)), + ], + ), + const SizedBox(width: S.s4), + const Icon(Icons.chevron_right_rounded, color: C.placeholder), + ], + ), + ); + } +} diff --git a/lib/features/orders/order_detail_screen.dart b/lib/features/orders/order_detail_screen.dart new file mode 100644 index 0000000..17b30c3 --- /dev/null +++ b/lib/features/orders/order_detail_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +class OrderDetailScreen extends ConsumerStatefulWidget { + const OrderDetailScreen({super.key, required this.orderId}); + final int orderId; + + @override + ConsumerState createState() => _OrderDetailScreenState(); +} + +class _OrderDetailScreenState extends ConsumerState { + bool _full = false; + bool _busy = false; + + Future _run(Future Function() fn, {String? done}) async { + if (_busy) return; + setState(() => _busy = true); + try { + await mutate(ref, fn); + if (mounted && done != null) toast(context, done); + } catch (e) { + if (mounted) toast(context, errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _setQty(Order o, EndingQty l, int qty) { + if (qty < 1 || qty > 9999) return Future.value(); + return _run(() => ref.read(apiProvider).setLineQty(o.id, l.ending, qty)); + } + + Future _remove(Order o, EndingQty l) async { + final last = o.lines.length == 1; + final ok = await confirm( + context, + title: last ? 'Remove last ending?' : 'Remove ${l.ending}?', + body: last + ? 'This is the only ending in this order. Removing it will delete the order.' + : '${plural(l.qty, 'ticket')} will be removed from this order and the shop totals.', + confirmLabel: 'Remove', + ); + if (!ok || !mounted) return; + await _run( + () => ref.read(apiProvider).removeLine(o.id, l.ending), + done: last ? 'Order deleted' : 'Ending removed', + ); + if (last && mounted) context.pop(); + } + + Future _delete(Order o) async { + final ok = await confirm( + context, + title: 'Delete this order?', + body: + '${plural(o.tickets, 'ticket')} will be removed from ${o.shop.name}’s totals and results. This action cannot be undone.', + confirmLabel: 'Delete order', + ); + if (!ok || !mounted) return; + await _run( + () => ref.read(apiProvider).cancelOrder(o.id), + done: 'Order deleted', + ); + if (mounted) context.pop(); + } + + @override + Widget build(BuildContext context) { + final order = ref.watch(orderProvider(widget.orderId)); + return Scaffold( + body: SafeArea( + child: asyncView( + order, + (o) => Column( + children: [ + const ScreenHeader(title: 'Order details', back: true), + Expanded(child: _body(o)), + ], + ), + retry: () => ref.invalidate(orderProvider(widget.orderId)), + ), + ), + ); + } + + Widget _body(Order o) { + final out = + o.lines.any((l) => l.wins) || + o.drawDate.isBefore(ref.read(apiProvider).now); + final lines = (o.rawText ?? '').split('\n'); + final shown = _full ? lines : lines.take(4).toList(); + return PaneList( + top: 0, + breakpoint: 820, + startFlex: 1, + endFlex: 1, + gap: S.s24, + start: [ + SrsCard( + padding: const EdgeInsets.all(S.s16), + child: Row( + children: [ + CodeTile(o.shop.code, size: 52, dark: true), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + '${inr(o.tickets)} tickets', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(18, w: w7), + ), + ), + if (o.edited) ...[ + const SizedBox(width: S.s8), + const Pill('Edited', bg: C.goldTint, fg: C.goldText), + ], + ], + ), + const SizedBox(height: S.s4), + Text( + '${o.shop.code} · ${time12(o.time)} · ${o.isPhoto ? 'Photo' : 'Text'}', + style: ts(12, c: C.muted), + ), + const SizedBox(height: S.s2), + Text(dateLong(o.drawDate), style: ts(12, c: C.muted)), + ], + ), + ), + if (o.prize > 0) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + rupees(o.prize), + style: ts(16, w: w7, c: C.green), + ), + Text('won', style: ts(11, c: C.muted)), + ], + ), + ], + ), + ), + const SectionTitle('Original message'), + if (o.isPhoto) + Container( + height: 150, + alignment: Alignment.center, + decoration: BoxDecoration( + color: C.chip, + borderRadius: BorderRadius.circular(R.card), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo_outlined, size: 36, color: C.navy), + const SizedBox(height: S.s8), + Text( + 'Handwritten slip', + style: ts(13, w: w6, c: C.navy), + ), + const SizedBox(height: S.s2), + Text('Received via WhatsApp', style: ts(12, c: C.muted)), + ], + ), + ) + else + SrsCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shown.join('\n'), + style: ts(13, w: w5, c: C.ink, h: 1.6), + ), + if (lines.length > 4) ...[ + const SizedBox(height: S.s10), + Pressable( + onTap: () => setState(() => _full = !_full), + radius: R.sm, + child: Text( + _full ? 'Show less' : 'Show full message', + style: ts(13, w: w6, c: C.navy), + ), + ), + ], + ], + ), + ), + ], + end: [ + SectionTitle( + 'Endings', + trailing: + '${plural(o.lines.length, 'ending')} · ${plural(o.tickets, 'ticket')}', + ), + for (final l in o.lines) ...[ + _LineRow( + line: l, + busy: _busy, + onMinus: () => _setQty(o, l, l.qty - 1), + onPlus: () => _setQty(o, l, l.qty + 1), + onRemove: () => _remove(o, l), + ), + const SizedBox(height: S.s8), + ], + const SizedBox(height: S.s6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: S.s4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, size: 15, color: C.muted), + const SizedBox(width: S.s8), + Expanded( + child: Text( + out + ? 'Changes are reflected in shop totals and results.' + : 'Changes are reflected in shop totals and, once published, in results.', + style: ts(12, c: C.muted, h: 1.45), + ), + ), + ], + ), + ), + const SizedBox(height: S.s22), + BigButton( + 'Delete order', + outline: true, + danger: true, + icon: Icons.delete_outline_rounded, + onPressed: _busy ? null : () => _delete(o), + ), + ], + ); + } +} + +class _LineRow extends StatelessWidget { + const _LineRow({ + required this.line, + required this.busy, + required this.onMinus, + required this.onPlus, + required this.onRemove, + }); + + final EndingQty line; + final bool busy; + final VoidCallback onMinus, onPlus, onRemove; + + @override + Widget build(BuildContext context) { + final win = line.wins; + return Container( + padding: const EdgeInsets.fromLTRB(S.s14, S.s10, S.s8, S.s10), + decoration: BoxDecoration( + color: win ? C.greenTint : Colors.white, + borderRadius: BorderRadius.circular(R.button), + border: Border.all(color: win ? C.green.withAlpha(110) : C.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + line.ending, + style: ts(18, w: w7, c: win ? C.green : C.ink, ls: 1), + ), + if (win) ...[ + const SizedBox(height: S.s2), + Text( + '${line.tierText} · ${rupees(line.prize)}', + style: ts(12, w: w6, c: C.green), + ), + ], + ], + ), + ), + _step( + Icons.remove_rounded, + 'Fewer tickets', + line.qty > 1 && !busy ? onMinus : null, + ), + SizedBox( + width: 44, + child: Center( + child: Text('${line.qty}', style: ts(16, w: w7)), + ), + ), + _step( + Icons.add_rounded, + 'More tickets', + line.qty < 9999 && !busy ? onPlus : null, + ), + const SizedBox(width: S.s6), + IconButton( + onPressed: busy ? null : onRemove, + icon: const Icon( + Icons.delete_outline_rounded, + size: 21, + color: C.red, + ), + tooltip: 'Remove ending', + ), + ], + ), + ); + } + + Widget _step(IconData icon, String label, VoidCallback? tap) => Pressable( + onTap: tap, + label: label, + radius: 18, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: 36, + height: 36, + decoration: BoxDecoration( + color: tap == null ? C.disabled : (hover ? C.border2 : C.chip), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 18, color: tap == null ? C.placeholder : C.navy), + ), + ); +} diff --git a/lib/features/orders/orders_screen.dart b/lib/features/orders/orders_screen.dart new file mode 100644 index 0000000..6845df4 --- /dev/null +++ b/lib/features/orders/orders_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import 'order_card.dart'; + +class OrdersScreen extends ConsumerStatefulWidget { + const OrdersScreen({super.key}); + + @override + ConsumerState createState() => _OrdersScreenState(); +} + +class _OrdersScreenState extends ConsumerState { + String? _code; + + @override + Widget build(BuildContext context) { + final date = ref.watch(selectedDateProvider); + final orders = ref.watch(ordersProvider(date)); + return Column( + children: [ + const ScreenHeader(title: 'Orders'), + const PagePad( + top: S.s4, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox(width: 420, child: DateStrip()), + ), + ), + Expanded( + child: asyncView(orders, (all) { + final codes = {for (final o in all) o.shop.code}.toList() + ..sort(); + final active = codes.contains(_code) ? _code : null; + final list = active == null + ? all + : all.where((o) => o.shop.code == active).toList(); + final tickets = list.fold(0, (a, o) => a + o.tickets); + return PageList( + top: S.s14, + children: [ + if (codes.length > 1) + SizedBox( + height: 38, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + _chip( + 'All', + active == null, + () => setState(() => _code = null), + ), + for (final c in codes) + _chip( + c, + active == c, + () => setState(() => _code = c), + ), + ], + ), + ), + if (list.isEmpty) + const EmptyState( + icon: Icons.receipt_long_outlined, + title: 'No orders for this date', + body: + 'Orders received via WhatsApp will appear here. Select another date to view earlier orders.', + ) + else ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + S.s2, + S.s18, + S.s2, + S.s10, + ), + child: Row( + children: [ + Expanded( + child: Text(dateLong(date), style: ts(15, w: w7)), + ), + Text( + '${plural(list.length, 'order')} · ${plural(tickets, 'ticket')}', + style: ts(12, c: C.muted), + ), + ], + ), + ), + CardGrid( + children: [ + for (final o in list) + OrderCard( + order: o, + onTap: () => context.push('/orders/${o.id}'), + ), + ], + ), + ], + ], + ); + }, retry: () => ref.invalidate(ordersProvider(date))), + ), + ], + ); + } + + Widget _chip(String t, bool on, VoidCallback tap) => Padding( + padding: const EdgeInsets.only(right: S.s8), + child: Pressable( + onTap: tap, + radius: R.pill, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(horizontal: S.s16), + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? C.navy : (hover ? C.hover : Colors.white), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: on ? C.navy : (hover ? C.border2 : C.border), + ), + ), + child: Text( + t, + style: ts(13, w: w6, c: on ? Colors.white : C.ink), + ), + ), + ), + ); +} diff --git a/lib/features/results/calendar_picker.dart b/lib/features/results/calendar_picker.dart new file mode 100644 index 0000000..a126e20 --- /dev/null +++ b/lib/features/results/calendar_picker.dart @@ -0,0 +1,394 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +const _panelWidth = 360.0; +Future showCalendarPicker(BuildContext anchor) { + if (!isWide(anchor)) { + return showModalBottomSheet( + context: anchor, + isScrollControlled: true, + useSafeArea: true, + useRootNavigator: true, + builder: (ctx) => const SingleChildScrollView( + padding: EdgeInsets.fromLTRB(S.s16, S.s10, S.s16, S.s20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_Grabber(), _CalendarPicker()], + ), + ), + ); + } + + final box = anchor.findRenderObject() as RenderBox?; + final rect = box != null && box.hasSize + ? box.localToGlobal(Offset.zero) & box.size + : null; + return showGeneralDialog( + context: anchor, + barrierDismissible: true, + barrierLabel: 'Close calendar', + barrierColor: const Color(0x1A101B33), + transitionDuration: const Duration(milliseconds: 160), + pageBuilder: (ctx, _, _) => CustomSingleChildLayout( + delegate: _AnchorLayout(rect), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(R.dialog), + boxShadow: const [ + BoxShadow( + color: C.shadowNavy, + blurRadius: 32, + offset: Offset(0, 12), + ), + ], + ), + child: Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(R.dialog), + side: const BorderSide(color: C.border), + ), + clipBehavior: Clip.antiAlias, + child: const SizedBox( + width: _panelWidth, + child: Padding( + padding: EdgeInsets.all(S.s16), + child: _CalendarPicker(), + ), + ), + ), + ), + ), + transitionBuilder: (ctx, a, _, child) { + if (MediaQuery.disableAnimationsOf(ctx)) return child; + final t = CurvedAnimation(parent: a, curve: Curves.easeOutCubic); + return FadeTransition( + opacity: t, + child: ScaleTransition( + scale: Tween(begin: 0.97, end: 1.0).animate(t), + alignment: Alignment.topCenter, + child: child, + ), + ); + }, + ); +} + +class _AnchorLayout extends SingleChildLayoutDelegate { + _AnchorLayout(this.anchor); + final Rect? anchor; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints c) => + BoxConstraints.loose(Size(c.maxWidth - 32, c.maxHeight - 32)); + + @override + Offset getPositionForChild(Size size, Size child) { + const m = S.s16; + final a = anchor; + if (a == null) { + return Offset( + (size.width - child.width) / 2, + (size.height - child.height) / 2, + ); + } + final left = (a.center.dx - child.width / 2).clamp( + m, + size.width - child.width - m, + ); + var top = a.bottom + S.s8; + if (top + child.height > size.height - m) { + final above = a.top - S.s8 - child.height; + top = above >= m ? above : (size.height - child.height) / 2; + } + return Offset(left, top); + } + + @override + bool shouldRelayout(_AnchorLayout old) => old.anchor != anchor; +} + +class _Grabber extends StatelessWidget { + const _Grabber(); + + @override + Widget build(BuildContext context) => Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: S.s12), + decoration: BoxDecoration( + color: C.border2, + borderRadius: BorderRadius.circular(R.sm), + ), + ); +} + +class _CalendarPicker extends ConsumerStatefulWidget { + const _CalendarPicker(); + + @override + ConsumerState<_CalendarPicker> createState() => _CalendarPickerState(); +} + +class _CalendarPickerState extends ConsumerState<_CalendarPicker> { + late DateTime _picked = ref.read(selectedDateProvider); + late DateTime _month = DateTime(_picked.year, _picked.month); + + void _shift(int by) => + setState(() => _month = DateTime(_month.year, _month.month + by)); + + void _pick(DateTime d) => setState(() { + _picked = d; + _month = DateTime(d.year, d.month); + }); + + void _confirm() { + ref.read(selectedDateProvider.notifier).set(_picked); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final today = dateOnly(ref.read(apiProvider).now); + final days = ref.watch(monthProvider((_month.year, _month.month))); + final byDay = { + for (final d in days.asData?.value ?? const []) d.date.day: d, + }; + final pickedInfo = ref + .watch(monthProvider((_picked.year, _picked.month))) + .asData + ?.value + .where((d) => sameDay(d.date, _picked)) + .firstOrNull; + + final first = DateTime(_month.year, _month.month); + final lead = first.weekday - 1; + final count = DateTime(_month.year, _month.month + 1, 0).day; + final rows = ((lead + count) / 7).ceil(); + + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowLeft): () => + _pick(addDays(_picked, -1)), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => + _pick(addDays(_picked, 1)), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => + _pick(addDays(_picked, -7)), + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + _pick(addDays(_picked, 7)), + }, + child: Focus( + autofocus: true, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const SizedBox(width: S.s4), + Expanded( + child: Text(monthYear(_month), style: ts(18, w: w7)), + ), + if (!sameDay(DateTime(today.year, today.month), _month) || + !sameDay(_picked, today)) + Padding( + padding: const EdgeInsets.only(right: S.s4), + child: Pressable( + onTap: () => _pick(today), + radius: R.pill, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 32, + padding: const EdgeInsets.symmetric(horizontal: S.s12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: hover ? C.chip : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text( + 'Today', + style: ts(13, w: w6, c: C.navy), + ), + ), + ), + ), + RoundIcon( + Icons.chevron_left_rounded, + label: 'Previous month', + size: 36, + onTap: () => _shift(-1), + ), + const SizedBox(width: S.s6), + RoundIcon( + Icons.chevron_right_rounded, + label: 'Next month', + size: 36, + onTap: () => _shift(1), + ), + ], + ), + const SizedBox(height: S.s14), + Row( + children: [ + for (final w in const ['M', 'T', 'W', 'T', 'F', 'S', 'S']) + Expanded( + child: Center( + child: Text( + w, + style: ts(12, w: w6, c: C.muted), + ), + ), + ), + ], + ), + const SizedBox(height: S.s6), + for (var r = 0; r < rows; r++) + Row( + children: [ + for (var c = 0; c < 7; c++) + Expanded( + child: _cell(r * 7 + c - lead + 1, count, byDay, today), + ), + ], + ), + const SizedBox(height: S.s12), + _Summary(date: _picked, info: pickedInfo), + const SizedBox(height: S.s14), + BigButton( + 'Show results for ${dateShort(_picked)}', + onPressed: _confirm, + ), + ], + ), + ), + ); + } + + Widget _cell(int day, int count, Map byDay, DateTime today) { + if (day < 1 || day > count) return const SizedBox(height: 48); + final date = DateTime(_month.year, _month.month, day); + final sel = sameDay(date, _picked); + final isToday = sameDay(date, today); + final info = byDay[day]; + final out = info?.published == true; + final win = info?.hasWin == true; + final status = [ + if (out) 'results published', + if (win) 'winning tickets', + ].join(', '); + return Pressable( + onTap: () => _pick(date), + label: status.isEmpty ? dateLong(date) : '${dateLong(date)}, $status', + radius: R.md, + builder: (_, hover) => Padding( + padding: const EdgeInsets.all(S.s2), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + height: 44, + decoration: BoxDecoration( + color: sel ? C.navy : (hover ? C.chip : Colors.transparent), + borderRadius: BorderRadius.circular(R.md), + border: !sel && isToday + ? Border.all(color: C.navy, width: 1.4) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$day', + style: ts( + 14, + w: sel || isToday ? w7 : w5, + c: sel ? Colors.white : C.ink, + ), + ), + const SizedBox(height: S.s4), + SizedBox( + height: 4, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (out) + Container( + width: 12, + height: 4, + decoration: BoxDecoration( + color: sel ? C.softOnNavy : C.green, + borderRadius: BorderRadius.circular(2), + ), + ), + if (out && win) const SizedBox(width: 3), + if (win) + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: C.gold, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Summary extends StatelessWidget { + const _Summary({required this.date, required this.info}); + final DateTime date; + final DrawInfo? info; + + @override + Widget build(BuildContext context) { + final out = info?.published == true; + final win = info?.hasWin == true; + final (icon, color, text) = !out + ? (Icons.schedule_rounded, C.goldText, 'Results not published yet') + : win + ? (Icons.star_rounded, C.goldText, 'Results out, with winning tickets') + : (Icons.check_circle_rounded, C.green, 'Results out'); + return Container( + padding: const EdgeInsets.all(S.s12), + decoration: BoxDecoration( + color: C.bg, + borderRadius: BorderRadius.circular(R.input), + ), + child: Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: S.s10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + out && info!.code != null + ? '${info!.scheme ?? 'Draw'} ${info!.code}' + : dateLong(date), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(14, w: w6), + ), + const SizedBox(height: S.s2), + Text(text, style: ts(12, c: C.muted)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/results/results_screen.dart b/lib/features/results/results_screen.dart new file mode 100644 index 0000000..e3b25c1 --- /dev/null +++ b/lib/features/results/results_screen.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/account_sheet.dart'; +import 'calendar_picker.dart'; +import '../../widgets/ui.dart'; + +class ResultsScreen extends ConsumerWidget { + const ResultsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final date = ref.watch(selectedDateProvider); + final res = ref.watch(resultsProvider(date)); + return Column( + children: [ + ScreenHeader( + title: 'Results', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (anchor) => RoundIcon( + Icons.calendar_month_rounded, + label: 'Calendar', + onTap: () => showCalendarPicker(anchor), + ), + ), + // Wider windows show the account in the sidebar. + if (!isWide(context)) ...[ + const SizedBox(width: S.s10), + RoundIcon( + Icons.person_outline_rounded, + label: 'Account', + onTap: () => showAccountSheet(context, ref), + ), + ], + ], + ), + ), + Expanded( + child: asyncView( + res, + (r) => _Body(results: r, date: date), + retry: () => ref.invalidate(resultsProvider(date)), + ), + ), + ], + ); + } +} + +class _Body extends ConsumerWidget { + const _Body({required this.results, required this.date}); + final DrawResults results; + final DateTime date; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final r = results; + final d = r.draw; + final n = ref.read(selectedDateProvider.notifier); + return PageList( + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(d.label, style: ts(16, w: w7)), + const SizedBox(height: S.s2), + Text( + '${dateLong(d.date)} · 3:00 PM', + style: ts(13, c: C.muted), + ), + ], + ), + ), + RoundIcon( + Icons.chevron_left_rounded, + label: 'Previous draw', + size: 40, + onTap: () => n.set(addDays(date, -1)), + ), + const SizedBox(width: S.s8), + RoundIcon( + Icons.chevron_right_rounded, + label: 'Next draw', + size: 40, + onTap: () => n.set(addDays(date, 1)), + ), + ], + ), + const SizedBox(height: S.s16), + if (d.published) ..._published(context, r) else ..._notOut(context, r), + ], + ); + } + + List _published(BuildContext context, DrawResults r) { + final top = r.topShop; + return [ + _HeroRow( + hero: NavyCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TOTAL PRIZE', + style: ts(11, w: w6, c: C.softOnNavy, ls: 1.2), + ), + const SizedBox(height: S.s10), + Text( + rupees(r.totalPrize), + style: ts( + 40, + w: w7, + c: r.totalPrize > 0 ? C.gold : C.softOnNavy, + ls: -1, + ), + ), + const SizedBox(height: S.s10), + Text( + r.totalPrize > 0 + ? '${plural(r.winningTickets, 'winning ticket')} · ${plural(r.winningShops, 'shop')}' + : 'No winning tickets in this draw', + style: ts(13, w: w5, c: C.softOnNavy), + ), + ], + ), + ), + stats: [ + StatCard(label: 'TOTAL TICKETS', value: inr(r.totalTickets)), + StatCard( + label: 'TOP SHOP', + value: top?.shop.code ?? '—', + valueColor: top == null ? C.muted : C.ink, + ), + ], + ), + SectionTitle( + 'Shops', + trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'), + ), + if (r.shops.isEmpty) + const SrsCard( + child: EmptyState( + icon: Icons.receipt_long_outlined, + title: 'No orders for this draw', + body: 'Orders placed for your shops on this date will appear here.', + ), + ) + else + CardGrid(children: [for (final s in r.shops) _ShopRow(s: s)]), + ]; + } + + List _notOut(BuildContext context, DrawResults r) { + return [ + _HeroRow( + hero: NavyCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: S.s10, + vertical: S.s6, + ), + decoration: BoxDecoration( + color: C.goldSoft, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.schedule_rounded, size: 14, color: C.gold), + const SizedBox(width: S.s6), + Text( + 'AWAITING RESULTS', + style: ts(10, w: w7, c: C.gold, ls: 1), + ), + ], + ), + ), + const SizedBox(height: S.s14), + Text( + 'Results not yet published', + style: ts(26, w: w7, c: Colors.white, ls: -0.4), + ), + const SizedBox(height: S.s8), + Text( + 'Official results are usually published after 4 PM. Prize details will appear here once available.', + style: ts(13, c: C.softOnNavy, h: 1.5), + ), + ], + ), + ), + stats: [ + StatCard(label: 'ORDERS', value: inr(r.orders)), + StatCard(label: 'TICKETS', value: inr(r.totalTickets)), + ], + ), + SectionTitle( + 'Shops', + trailing: r.shops.isEmpty ? null : plural(r.shops.length, 'shop'), + ), + if (r.shops.isEmpty) + const SrsCard( + child: EmptyState( + icon: Icons.inbox_outlined, + title: 'No orders', + body: 'No orders have been placed for this draw.', + ), + ) + else + CardGrid( + children: [for (final s in r.shops) _ShopRow(s: s, pending: true)], + ), + ]; + } +} + +/// The navy hero with its two stat cards: stacked under it on phones, +/// beside it on wide windows. +class _HeroRow extends StatelessWidget { + const _HeroRow({required this.hero, required this.stats}); + final Widget hero; + final List stats; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + if (c.maxWidth < 760) { + return Column( + children: [ + hero, + const SizedBox(height: S.s12), + Row( + children: [ + Expanded(child: stats[0]), + const SizedBox(width: S.s12), + Expanded(child: stats[1]), + ], + ), + ], + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 2, child: hero), + const SizedBox(width: S.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: stats[0]), + const SizedBox(height: S.s12), + Expanded(child: stats[1]), + ], + ), + ), + ], + ), + ); + }, + ); +} + +class _ShopRow extends StatelessWidget { + const _ShopRow({required this.s, this.pending = false}); + final ShopResult s; + final bool pending; + + @override + Widget build(BuildContext context) { + final win = s.prize > 0; + return SrsCard( + onTap: () => context.push('/shops/${s.shop.id}'), + padding: const EdgeInsets.all(S.s14), + child: Row( + children: [ + CodeTile(s.shop.code, dark: win), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.shop.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6), + ), + const SizedBox(height: S.s4), + Text( + pending + ? plural(s.tickets, 'ticket') + : win + ? plural(s.winTickets, 'winning ticket') + : 'No winning tickets', + style: ts(12, c: C.muted), + ), + ], + ), + ), + if (!pending) + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + rupees(s.prize), + style: ts(16, w: w7, c: win ? C.green : C.muted), + ), + if (win) ...[ + const SizedBox(height: S.s2), + Text( + plural(s.winTickets, 'ticket'), + style: ts(11, c: C.muted), + ), + ], + ], + ) + else + const Icon(Icons.chevron_right_rounded, color: C.placeholder), + ], + ), + ); + } +} diff --git a/lib/features/shops/shop_detail_screen.dart b/lib/features/shops/shop_detail_screen.dart new file mode 100644 index 0000000..31f77b0 --- /dev/null +++ b/lib/features/shops/shop_detail_screen.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import 'shop_sheet.dart'; + +class ShopDetailScreen extends ConsumerWidget { + const ShopDetailScreen({super.key, required this.shopId}); + final int shopId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final date = ref.watch(selectedDateProvider); + final detail = ref.watch(shopDetailProvider((shopId, date))); + final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false; + return Scaffold( + body: SafeArea( + child: asyncView( + detail, + (d) => Column( + children: [ + ScreenHeader( + title: d.shop.name, + back: true, + maxWidth: _maxW, + trailing: isAdmin + ? RoundIcon( + Icons.edit_outlined, + label: 'Edit shop', + onTap: () => showShopSheet( + context, + shop: d.shop, + onDeactivated: () { + if (context.mounted) context.go('/shops'); + }, + ), + ) + : null, + ), + Expanded( + child: _Body(d: d, date: date), + ), + ], + ), + retry: () => ref.invalidate(shopDetailProvider((shopId, date))), + ), + ), + ); + } +} + +const _maxW = 960.0; + +class _Body extends StatelessWidget { + const _Body({required this.d, required this.date}); + final ShopDetail d; + final DateTime date; + + @override + Widget build(BuildContext context) { + final out = d.draw.published; + final wins = d.wins; + return PageList( + maxWidth: _maxW, + top: 0, + children: [ + Row( + children: [ + Pill(d.shop.code, bg: C.chip, fg: C.navy), + const SizedBox(width: S.s10), + Expanded( + child: Text( + '${d.draw.label} · ${dateShort(date)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(13, c: C.muted), + ), + ), + ], + ), + const SizedBox(height: S.s16), + if (out) + NavyCard( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'WINNING ENDINGS', + style: ts(10, w: w6, c: C.softOnNavy, ls: 1), + ), + const SizedBox(height: S.s8), + Text( + '${wins.length}', + style: ts(34, w: w7, c: Colors.white, ls: -0.5), + ), + const SizedBox(height: S.s2), + Text( + 'of ${inr(d.endings.length)} endings', + style: ts(12, c: C.softOnNavy), + ), + ], + ), + ), + Container(width: 1, height: 64, color: C.onNavy20), + const SizedBox(width: S.s22), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PRIZE AMOUNT', + style: ts(10, w: w6, c: C.softOnNavy, ls: 1), + ), + const SizedBox(height: S.s8), + Text( + rupees(d.prize), + style: ts( + 30, + w: w7, + c: d.prize > 0 ? C.gold : C.softOnNavy, + ls: -0.5, + ), + ), + const SizedBox(height: S.s2), + Text( + plural(d.winTickets, 'winning ticket'), + style: ts(12, c: C.softOnNavy), + ), + ], + ), + ), + ], + ), + ) + else + NavyCard( + child: Row( + children: [ + const Icon(Icons.schedule_rounded, color: C.gold, size: 28), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Results not yet published', + style: ts(18, w: w7, c: Colors.white), + ), + const SizedBox(height: S.s4), + Text( + 'Prize details will appear here once results are published.', + style: ts(12, c: C.softOnNavy), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: S.s12), + Row( + children: [ + Expanded( + child: StatCard(label: 'TICKETS', value: inr(d.tickets)), + ), + const SizedBox(width: S.s12), + Expanded( + child: StatCard( + label: 'PRIZE WON', + value: out ? rupees(d.prize) : '—', + valueColor: out && d.prize > 0 ? C.green : C.muted, + ), + ), + ], + ), + if (out) ...[ + SectionTitle( + 'Winning endings', + trailing: wins.isEmpty ? null : '${wins.length}', + ), + if (wins.isEmpty) + const SrsCard( + child: EmptyState( + icon: Icons.sentiment_neutral_rounded, + title: 'No winning endings', + body: + 'None of this shop’s endings matched a prize in this draw.', + ), + ) + else + CardGrid( + minItemWidth: 320, + maxColumns: 2, + children: [for (final w in wins) _WinRow(w: w)], + ), + ], + const SizedBox(height: S.s14), + SrsCard( + onTap: d.tickets == 0 + ? null + : () => context.push('/shops/${d.shop.id}/tickets'), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: C.chip, + borderRadius: BorderRadius.circular(R.md), + ), + child: const Icon( + Icons.confirmation_number_outlined, + color: C.navy, + size: 22, + ), + ), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tickets · ${inr(d.tickets)}', style: ts(15, w: w6)), + const SizedBox(height: S.s2), + Text( + d.tickets == 0 + ? 'No orders for this draw' + : '${plural(d.endings.length, 'ending')} · ${plural(d.orders.length, 'order')}', + style: ts(12, c: C.muted), + ), + ], + ), + ), + if (d.tickets > 0) + const Icon(Icons.chevron_right_rounded, color: C.placeholder), + ], + ), + ), + ], + ); + } +} + +class _WinRow extends StatelessWidget { + const _WinRow({required this.w}); + final EndingQty w; + + @override + Widget build(BuildContext context) => SrsCard( + padding: const EdgeInsets.all(S.s14), + child: Row( + children: [ + Container( + width: 62, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: C.greenTint, + borderRadius: BorderRadius.circular(R.input), + ), + child: Text( + w.ending, + style: ts(17, w: w7, c: C.green, ls: 0.5), + ), + ), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('× ${plural(w.qty, 'ticket')}', style: ts(14, w: w6)), + const SizedBox(height: S.s4), + Text( + '${w.tierText} · ${rupees(w.perTicket)} each', + style: ts(12, c: C.muted), + ), + ], + ), + ), + Text( + rupees(w.prize), + style: ts(16, w: w7, c: C.green), + ), + ], + ), + ); +} diff --git a/lib/features/shops/shop_sheet.dart b/lib/features/shops/shop_sheet.dart new file mode 100644 index 0000000..6f370af --- /dev/null +++ b/lib/features/shops/shop_sheet.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +Future showShopSheet( + BuildContext context, { + Shop? shop, + VoidCallback? onDeactivated, +}) { + return showSheet( + context, + (ctx) => _ShopForm(shop: shop, onDeactivated: onDeactivated), + ); +} + +class _ShopForm extends ConsumerStatefulWidget { + const _ShopForm({this.shop, this.onDeactivated}); + final Shop? shop; + final VoidCallback? onDeactivated; + + @override + ConsumerState<_ShopForm> createState() => _ShopFormState(); +} + +class _ShopFormState extends ConsumerState<_ShopForm> { + late final _name = TextEditingController(text: widget.shop?.name ?? ''); + final _code = TextEditingController(); + late bool _active = widget.shop?.active ?? true; + bool _codeTouched = false; + bool _busy = false; + String? _error; + + bool get _edit => widget.shop != null; + + @override + void dispose() { + _name.dispose(); + _code.dispose(); + super.dispose(); + } + + void _nameChanged(String v) { + if (!_edit && !_codeTouched) { + final letters = v.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); + _code.text = letters.length > 3 ? letters.substring(0, 3) : letters; + } + setState(() => _error = null); + } + + Future _save() async { + setState(() { + _busy = true; + _error = null; + }); + final api = ref.read(apiProvider); + try { + await mutate( + ref, + () => _edit + ? api.renameShop(widget.shop!.id, _name.text) + : api.createShop(_name.text, _code.text), + ); + if (!mounted) return; + final msg = _edit ? 'Shop updated' : 'Shop added'; + Navigator.pop(context); + toast(context, msg); + } catch (e) { + if (mounted) setState(() => _error = errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _toggle(bool on) async { + final shop = widget.shop!; + final ok = await confirm( + context, + title: on ? 'Activate ${shop.name}?' : 'Deactivate ${shop.name}?', + body: on + ? 'This shop will be available again when saving orders.' + : 'This shop will no longer be available when saving orders. Existing orders are kept.', + confirmLabel: on ? 'Activate' : 'Deactivate', + danger: !on, + ); + if (!ok || !mounted) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await mutate(ref, () => ref.read(apiProvider).setShopActive(shop.id, on)); + if (!mounted) return; + setState(() => _active = on); + if (!on) { + final cb = widget.onDeactivated; + Navigator.pop(context); + toast(context, 'Shop deactivated'); + cb?.call(); + } else { + toast(context, 'Shop activated'); + } + } catch (e) { + if (mounted) setState(() => _error = errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final code = _edit ? widget.shop!.code : _code.text.trim().toUpperCase(); + final name = _name.text.trim(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_edit ? 'Edit shop' : 'Add shop', style: ts(20, w: w7)), + const SizedBox(height: S.s4), + Text( + _edit + ? 'You can rename a shop. The short code cannot be changed.' + : 'Create a shop to group and track its orders.', + style: ts(13, c: C.muted), + ), + const SizedBox(height: S.s20), + SrsField( + controller: _name, + label: 'Shop name', + hint: 'e.g. Kazhakkoottam', + autofocus: true, + textCapitalization: TextCapitalization.words, + onChanged: _nameChanged, + ), + const SizedBox(height: S.s16), + SrsField( + controller: _edit + ? TextEditingController(text: widget.shop!.code) + : _code, + label: 'Short code', + hint: 'e.g. KZK', + enabled: !_edit, + textCapitalization: TextCapitalization.characters, + helper: _edit + ? 'The short code is permanent.' + : 'Up to 20 characters, no spaces.', + onChanged: (_) => setState(() { + _codeTouched = true; + _error = null; + }), + ), + const SizedBox(height: S.s16), + Container( + padding: const EdgeInsets.all(S.s14), + decoration: BoxDecoration( + color: C.bg, + borderRadius: BorderRadius.circular(R.input), + ), + child: Row( + children: [ + CodeTile(code.isEmpty ? '···' : code, size: 38), + const SizedBox(width: S.s12), + Expanded( + child: Text( + '${code.isEmpty ? 'CODE' : code} · ${name.isEmpty ? 'Shop name' : name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts( + 14, + w: w6, + c: code.isEmpty && name.isEmpty ? C.placeholder : C.ink, + ), + ), + ), + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: S.s12), + ErrorLine(_error!), + ], + const SizedBox(height: S.s20), + BigButton( + _edit ? 'Save changes' : 'Add shop', + loading: _busy, + onPressed: name.isEmpty || (!_edit && code.isEmpty) ? null : _save, + ), + if (_edit) ...[ + const SizedBox(height: S.s14), + Container( + padding: const EdgeInsets.fromLTRB(S.s16, S.s10, S.s10, S.s10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(R.input), + border: Border.all(color: C.border), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _active ? 'Active' : 'Inactive', + style: ts(14, w: w6), + ), + const SizedBox(height: S.s2), + Text( + _active + ? 'Available when saving orders.' + : 'Unavailable when saving orders. Existing orders are kept.', + style: ts(12, c: C.muted), + ), + ], + ), + ), + Switch( + value: _active, + onChanged: _busy ? null : _toggle, + activeTrackColor: C.green, + activeThumbColor: Colors.white, + ), + ], + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/shops/shops_screen.dart b/lib/features/shops/shops_screen.dart new file mode 100644 index 0000000..22fec23 --- /dev/null +++ b/lib/features/shops/shops_screen.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import 'shop_sheet.dart'; + +class ShopsScreen extends ConsumerStatefulWidget { + const ShopsScreen({super.key}); + + @override + ConsumerState createState() => _ShopsScreenState(); +} + +class _ShopsScreenState extends ConsumerState { + String _q = ''; + + @override + Widget build(BuildContext context) { + final date = ref.watch(selectedDateProvider); + final isAdmin = ref.watch(sessionProvider)?.isAdmin ?? false; + final shops = ref.watch(shopsProvider(date)); + final count = shops.asData?.value.where((s) => s.shop.active).length; + + return Column( + children: [ + ScreenHeader( + title: 'Shops', + subtitle: count == null + ? null + : '${plural(count, 'shop')} · ${dateShort(date)}', + trailing: isAdmin + ? SmallButton('Add shop', onTap: () => showShopSheet(context)) + : null, + ), + PagePad( + top: S.s4, + bottom: S.s6, + child: _Toolbar( + search: SearchField( + hint: 'Search shops', + onChanged: (v) => setState(() => _q = v.trim().toLowerCase()), + ), + ), + ), + Expanded( + child: asyncView(shops, (list) { + final f = list + .where( + (s) => + _q.isEmpty || + s.shop.name.toLowerCase().contains(_q) || + s.shop.code.toLowerCase().contains(_q), + ) + .toList(); + if (f.isEmpty) { + return ListView( + children: [ + EmptyState( + icon: Icons.storefront_outlined, + title: list.isEmpty + ? 'No shops yet' + : 'No results for “$_q”', + body: list.isEmpty + ? (isAdmin + ? 'Add a shop to get started.' + : 'No shops are assigned to your account. Contact your administrator.') + : null, + ), + ], + ); + } + return PageList( + top: S.s8, + children: [ + CardGrid(children: [for (final s in f) _ShopTile(s: s)]), + ], + ); + }, retry: () => ref.invalidate(shopsProvider(date))), + ), + ], + ); + } +} + +class _Toolbar extends StatelessWidget { + const _Toolbar({required this.search}); + final Widget search; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + if (c.maxWidth < 760) { + return Column( + children: [ + const DateStrip(), + const SizedBox(height: S.s12), + search, + ], + ); + } + return Row( + children: [ + const SizedBox(width: 380, child: DateStrip()), + const SizedBox(width: S.s16), + Expanded(child: search), + ], + ); + }, + ); +} + +class _ShopTile extends StatelessWidget { + const _ShopTile({required this.s}); + final ShopDay s; + + @override + Widget build(BuildContext context) => + Opacity(opacity: s.shop.active ? 1 : 0.62, child: _card(context)); + + Widget _card(BuildContext context) => SrsCard( + onTap: () => s.shop.active + ? context.push('/shops/${s.shop.id}') + : showShopSheet(context, shop: s.shop), + padding: const EdgeInsets.all(S.s14), + child: Row( + children: [ + CodeTile(s.shop.code), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.shop.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6), + ), + const SizedBox(height: S.s4), + Text( + !s.shop.active + ? 'Inactive' + : s.orders == 0 + ? 'No orders' + : '${plural(s.tickets, 'ticket')} · ${plural(s.orders, 'order')}', + style: ts(12, c: C.muted), + ), + ], + ), + ), + if (s.shop.active) + const Icon(Icons.chevron_right_rounded, color: C.placeholder) + else + const Pill('Inactive', bg: C.redTint, fg: C.red), + ], + ), + ); +} diff --git a/lib/features/shops/tickets_screen.dart b/lib/features/shops/tickets_screen.dart new file mode 100644 index 0000000..38e8ef4 --- /dev/null +++ b/lib/features/shops/tickets_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import '../orders/order_card.dart'; + +class TicketsScreen extends ConsumerStatefulWidget { + const TicketsScreen({super.key, required this.shopId}); + final int shopId; + + @override + ConsumerState createState() => _TicketsScreenState(); +} + +class _TicketsScreenState extends ConsumerState { + bool _byEnding = true; + String _q = ''; + + @override + Widget build(BuildContext context) { + final date = ref.watch(selectedDateProvider); + final detail = ref.watch(shopDetailProvider((widget.shopId, date))); + return Scaffold( + body: SafeArea( + child: asyncView( + detail, + (d) => Column( + children: [ + ScreenHeader( + title: '${d.shop.name} tickets', + subtitle: '${plural(d.tickets, 'ticket')} · ${dateShort(date)}', + back: true, + ), + PagePad( + top: S.s4, + bottom: S.s10, + child: Container( + constraints: const BoxConstraints(maxWidth: 420), + padding: const EdgeInsets.all(S.s4), + decoration: BoxDecoration( + color: C.chip, + borderRadius: BorderRadius.circular(R.input), + ), + child: Row( + children: [ + _seg( + 'By ending', + _byEnding, + () => setState(() => _byEnding = true), + ), + _seg( + 'By order', + !_byEnding, + () => setState(() => _byEnding = false), + ), + ], + ), + ), + ), + if (_byEnding) + PagePad( + bottom: S.s8, + child: SearchField( + hint: 'Search endings (e.g. 0987)', + onChanged: (v) => setState(() => _q = v.trim()), + ), + ), + Expanded(child: _byEnding ? _endings(d) : _orders(d)), + ], + ), + retry: () => + ref.invalidate(shopDetailProvider((widget.shopId, date))), + ), + ), + ); + } + + Widget _seg(String t, bool on, VoidCallback tap) => Expanded( + child: Pressable( + onTap: tap, + radius: R.md, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? Colors.white : (hover ? C.hover : Colors.transparent), + borderRadius: BorderRadius.circular(R.md), + ), + child: Text( + t, + style: ts(13, w: on ? w7 : w5, c: on ? C.navy : C.muted), + ), + ), + ), + ); + + Widget _endings(ShopDetail d) { + final list = d.endings + .where((e) => _q.isEmpty || e.ending.contains(_q)) + .toList(); + if (list.isEmpty) { + return ListView( + children: const [ + EmptyState( + icon: Icons.search_off_rounded, + title: 'No matching endings', + ), + ], + ); + } + return PageList( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [for (final e in list) _chip(e)], + ), + ], + ); + } + + Widget _chip(EndingQty e) { + final win = e.wins; + return Container( + padding: const EdgeInsets.symmetric(horizontal: S.s12, vertical: S.s8), + decoration: BoxDecoration( + color: win ? C.greenTint : Colors.white, + borderRadius: BorderRadius.circular(R.md), + border: Border.all(color: win ? C.green : C.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + e.ending, + style: ts(14, w: w7, c: win ? C.green : C.ink, ls: 0.4), + ), + const SizedBox(width: S.s6), + Text( + '×${inr(e.qty)}', + style: ts(12, w: w6, c: win ? C.green : C.muted), + ), + ], + ), + ); + } + + Widget _orders(ShopDetail d) { + if (d.orders.isEmpty) { + return ListView( + children: const [ + EmptyState(icon: Icons.receipt_long_outlined, title: 'No orders'), + ], + ); + } + return PageList( + children: [ + CardGrid( + children: [ + for (final o in d.orders) + OrderCard(order: o, onTap: () => context.push('/orders/${o.id}')), + ], + ), + ], + ); + } +} diff --git a/lib/features/users/shop_picker.dart b/lib/features/users/shop_picker.dart new file mode 100644 index 0000000..115920b --- /dev/null +++ b/lib/features/users/shop_picker.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../core/models.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; + +class ShopPicker extends StatelessWidget { + const ShopPicker({ + super.key, + required this.shops, + required this.selected, + required this.onToggle, + }); + final List shops; + final Set selected; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + if (shops.isEmpty) { + return Text( + 'No active shops available. Add a shop first.', + style: ts(13, c: C.muted), + ); + } + return Column( + children: [ + for (final s in shops) + Padding( + padding: const EdgeInsets.only(bottom: S.s8), + child: Pressable( + onTap: () => onToggle(s.id), + radius: R.input, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric( + horizontal: S.s14, + vertical: S.s12, + ), + decoration: BoxDecoration( + color: selected.contains(s.id) + ? C.chip + : (hover ? C.hover : Colors.white), + borderRadius: BorderRadius.circular(R.input), + border: Border.all( + color: selected.contains(s.id) ? C.navy : C.border, + ), + ), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: selected.contains(s.id) ? C.navy : Colors.white, + borderRadius: BorderRadius.circular(R.sm), + border: Border.all( + color: selected.contains(s.id) ? C.navy : C.border2, + width: 1.5, + ), + ), + child: selected.contains(s.id) + ? const Icon( + Icons.check_rounded, + size: 16, + color: Colors.white, + ) + : null, + ), + const SizedBox(width: S.s12), + Text( + s.code, + style: ts(13, w: w7, c: C.navy), + ), + const SizedBox(width: S.s10), + Expanded( + child: Text( + s.name, + style: ts(14, w: w5), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/users/user_bits.dart b/lib/features/users/user_bits.dart new file mode 100644 index 0000000..a19605f --- /dev/null +++ b/lib/features/users/user_bits.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/theme.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({super.key, required this.user, this.size = 42}); + final AppUser user; + final double size; + + @override + Widget build(BuildContext context) { + final admin = user.isAdmin; + final dot = (size * 0.3).clamp(10.0, 18.0); + return SizedBox( + width: size + 2, + height: size + 2, + child: Stack( + children: [ + Opacity( + opacity: user.active ? 1 : 0.55, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: admin ? C.navy : C.chip, + borderRadius: BorderRadius.circular(size * 0.3), + ), + child: Text( + initials(user.username), + style: ts(size * 0.36, w: w7, c: admin ? C.gold : C.navy), + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: dot, + height: dot, + decoration: BoxDecoration( + color: user.active ? C.green : C.placeholder, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + ); + } +} + +class StatusLabel extends StatelessWidget { + const StatusLabel({super.key, required this.active}); + final bool active; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: active ? C.green : C.placeholder, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: S.s8), + Text( + active ? 'Active' : 'Inactive', + style: ts(13, w: w5, c: active ? C.ink : C.muted), + ), + ], + ); +} + +class ShopCodes extends StatelessWidget { + const ShopCodes({super.key, required this.shops}); + final List shops; + + @override + Widget build(BuildContext context) { + if (shops.isEmpty) { + return Text('No shops', style: ts(13, c: C.placeholder)); + } + const tile = 40.0, gap = S.s6, more = 36.0; + return LayoutBuilder( + builder: (context, c) { + var fit = ((c.maxWidth + gap) / (tile + gap)).floor(); + if (fit < shops.length) { + fit = ((c.maxWidth - more) / (tile + gap)).floor().clamp( + 0, + shops.length, + ); + } + final shown = shops.take(fit).toList(); + final rest = shops.length - shown.length; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final s in shown) + Padding( + padding: const EdgeInsets.only(right: gap), + child: Tooltip( + message: s.name, + child: Container( + width: tile, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: C.chip, + borderRadius: BorderRadius.circular(R.sm), + ), + child: Text( + s.code.length > 4 ? s.code.substring(0, 4) : s.code, + style: ts(11, w: w7, c: C.navy, ls: 0.3), + ), + ), + ), + ), + if (rest > 0) + Tooltip( + message: shops.skip(shown.length).map((s) => s.name).join(', '), + child: Container( + height: 26, + constraints: const BoxConstraints(minWidth: 32), + padding: const EdgeInsets.symmetric(horizontal: S.s6), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: C.border2), + borderRadius: BorderRadius.circular(R.sm), + ), + child: Text( + '+$rest', + style: ts(12, w: w7, c: C.navy), + ), + ), + ), + ], + ); + }, + ); + } +} + +class WhatsAppLogo extends StatelessWidget { + const WhatsAppLogo({super.key, this.size = 20, this.color}); + final double size; + final Color? color; + + static const brand = Color(0xFF25D366); + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: SvgPicture.asset( + 'assets/icons/whatsapp.svg', + width: size, + height: size, + colorFilter: ColorFilter.mode(color ?? brand, BlendMode.srcIn), + ), + ); +} diff --git a/lib/features/users/user_detail_screen.dart b/lib/features/users/user_detail_screen.dart new file mode 100644 index 0000000..f94f321 --- /dev/null +++ b/lib/features/users/user_detail_screen.dart @@ -0,0 +1,715 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import 'shop_picker.dart'; +import 'user_bits.dart'; + +class UserDetailScreen extends ConsumerWidget { + const UserDetailScreen({super.key, required this.userId}); + final int userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(userProvider(userId)); + final shops = + ref.watch(activeShopsProvider).asData?.value ?? const []; + return Scaffold( + body: SafeArea( + child: asyncView( + user, + (u) => Column( + children: [ + const ScreenHeader( + title: 'User details', + back: true, + maxWidth: _maxW, + ), + Expanded( + child: _Body(u: u, shops: shops), + ), + ], + ), + retry: () => ref.invalidate(userProvider(userId)), + ), + ), + ); + } +} + +const _maxW = 1040.0; + +class _Body extends ConsumerWidget { + const _Body({required this.u, required this.shops}); + final AppUser u; + final List shops; + + Future _delete(BuildContext context, WidgetRef ref) async { + final ok = await confirm( + context, + title: 'Delete ${u.username}?', + body: + 'This permanently removes the account. Saved orders are kept. This action cannot be undone.', + confirmLabel: 'Delete user', + ); + if (!ok || !context.mounted) return; + try { + await mutate(ref, () => ref.read(apiProvider).deleteUser(u.id)); + if (!context.mounted) return; + context.pop(); + toast(context, 'User deleted'); + } catch (e) { + if (context.mounted) toast(context, errText(e)); + } + } + + Future _toggleActive(BuildContext context, WidgetRef ref) async { + final deactivate = u.active; + final ok = await confirm( + context, + title: deactivate + ? 'Deactivate ${u.username}?' + : 'Activate ${u.username}?', + body: deactivate + ? 'This user will no longer be able to sign in. Saved orders are kept.' + : 'This user will be able to sign in again with their current password.', + confirmLabel: deactivate ? 'Deactivate' : 'Activate', + danger: deactivate, + ); + if (!ok || !context.mounted) return; + try { + await mutate( + ref, + () => ref.read(apiProvider).updateUser(u.id, active: !deactivate), + ); + if (context.mounted) { + toast(context, deactivate ? 'User deactivated' : 'User activated'); + } + } catch (e) { + if (context.mounted) toast(context, errText(e)); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mine = shops.where((s) => u.shopIds.contains(s.id)).toList(); + return PaneList( + maxWidth: _maxW, + breakpoint: 760, + startFlex: 1, + endFlex: 1, + gap: S.s24, + start: [ + _Profile(u: u), + _Heading( + 'Shops', + action: mine.isEmpty ? 'Assign shops' : 'Edit shops', + onAction: () => _shopsSheet(context, ref), + ), + SrsCard( + padding: EdgeInsets.zero, + child: mine.isEmpty + ? Padding( + padding: const EdgeInsets.all(S.s16), + child: Text( + 'No shops yet. ${u.username} cannot see any orders or results until a shop is assigned.', + style: ts(13, c: C.muted, h: 1.45), + ), + ) + : Column( + children: [ + for (var i = 0; i < mine.length; i++) ...[ + if (i > 0) const Divider(height: 1, color: C.border), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: S.s16, + vertical: S.s12, + ), + child: Row( + children: [ + CodeTile(mine[i].code, size: 40), + const SizedBox(width: S.s12), + Expanded( + child: Text(mine[i].name, style: ts(15, w: w6)), + ), + ], + ), + ), + ], + ], + ), + ), + ], + end: [ + const _Heading('Sign-in details'), + SrsCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _SettingRow( + label: 'Username', + value: u.username, + onTap: () => _editSheet(context, ref, _Field.username), + first: true, + ), + const Divider(height: 1, color: C.border), + _SettingRow( + label: 'Phone', + value: u.phone == null ? 'Not set' : fmtPhone(u.phone), + muted: u.phone == null, + onTap: () => _editSheet(context, ref, _Field.phone), + ), + const Divider(height: 1, color: C.border), + _SettingRow( + label: 'Password', + value: 'Set a new one', + muted: true, + onTap: () => _editSheet(context, ref, _Field.password), + last: true, + ), + ], + ), + ), + const _Heading('Access'), + SrsCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _ToggleRow( + leading: Icon( + Icons.key_rounded, + size: 20, + color: u.active ? C.green : C.muted, + ), + title: 'Can sign in', + description: 'Off blocks the app. Orders stay.', + value: u.active, + onChanged: (_) => _toggleActive(context, ref), + ), + const Divider(height: 1, color: C.border), + _ResultsRow( + u: u, + onAddPhone: () => _editSheet(context, ref, _Field.phone), + ), + ], + ), + ), + const SizedBox(height: S.s24), + BigButton( + 'Delete user', + outline: true, + danger: true, + icon: Icons.delete_outline_rounded, + onPressed: () => _delete(context, ref), + ), + const SizedBox(height: S.s10), + Center( + child: Text( + 'Their saved orders are kept.', + style: ts(12, c: C.muted), + ), + ), + ], + ); + } + + void _editSheet(BuildContext context, WidgetRef ref, _Field f) { + showSheet(context, (_) => _EditField(user: u, field: f)); + } + + void _shopsSheet(BuildContext context, WidgetRef ref) { + showSheet(context, (_) => _ShopsEditor(user: u, shops: shops)); + } +} + +class _Profile extends StatelessWidget { + const _Profile({required this.u}); + final AppUser u; + + @override + Widget build(BuildContext context) => SrsCard( + padding: const EdgeInsets.all(S.s20), + child: Row( + children: [ + UserAvatar(user: u, size: 68), + const SizedBox(width: S.s16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + u.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(24, w: w7, ls: -0.3), + ), + const SizedBox(height: S.s8), + Wrap( + spacing: S.s10, + runSpacing: S.s6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + StatusLabel(active: u.active), + if (u.phone != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.phone_outlined, + size: 14, + color: C.muted, + ), + const SizedBox(width: S.s4), + Text(fmtPhone(u.phone), style: ts(13, c: C.muted)), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ); +} + +class _Heading extends StatelessWidget { + const _Heading(this.text, {this.action, this.onAction}); + final String text; + final String? action; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(S.s2, S.s22, 0, S.s10), + child: Row( + children: [ + Expanded( + child: Text(text, style: ts(17, w: w7)), + ), + if (action != null) _LinkButton(action!, onTap: onAction), + ], + ), + ); +} + +/// A quiet navy text action with a hover pill, for actions inside a section. +class _LinkButton extends StatelessWidget { + const _LinkButton(this.label, {required this.onTap}); + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Pressable( + onTap: onTap, + radius: R.pill, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 32, + padding: const EdgeInsets.symmetric(horizontal: S.s12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: hover ? C.chip : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.edit_outlined, size: 15, color: C.navy), + const SizedBox(width: S.s6), + Text( + label, + style: ts(13, w: w6, c: C.navy), + ), + ], + ), + ), + ); +} + +/// A setting with an on/off switch: icon, title, an optional short status +/// line, and the switch. +class _ToggleRow extends StatelessWidget { + const _ToggleRow({ + required this.leading, + required this.title, + required this.description, + required this.value, + required this.onChanged, + this.status, + }); + final Widget leading; + + /// The title, then one short line saying what the setting does. + final String title, description; + final Widget? status; + final bool value; + + /// Null disables the switch. + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s10, S.s12), + child: Row( + children: [ + SizedBox(width: 24, child: Center(child: leading)), + const SizedBox(width: S.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The switch carries the name for screen readers. + ExcludeSemantics(child: Text(title, style: ts(15, w: w6))), + const SizedBox(height: S.s2), + Text(description, style: ts(12, c: C.muted, h: 1.35)), + if (status != null) ...[const SizedBox(height: S.s4), status!], + ], + ), + ), + const SizedBox(width: S.s8), + Semantics( + label: title, + child: Switch( + value: value, + onChanged: onChanged, + activeTrackColor: C.green, + activeThumbColor: Colors.white, + ), + ), + ], + ), + ); +} + +/// "WhatsApp results". srs-api sends a summary for the user's shops after +/// each draw, only to active users with a phone number; the status line says +/// where it goes, or why it won't. +class _ResultsRow extends ConsumerStatefulWidget { + const _ResultsRow({required this.u, required this.onAddPhone}); + final AppUser u; + final VoidCallback onAddPhone; + + @override + ConsumerState<_ResultsRow> createState() => _ResultsRowState(); +} + +class _ResultsRowState extends ConsumerState<_ResultsRow> { + /// The value being saved, shown until the reloaded user agrees. + bool? _pending; + + @override + void didUpdateWidget(_ResultsRow old) { + super.didUpdateWidget(old); + if (_pending == widget.u.sendResults) _pending = null; + } + + Future _set(bool on) async { + if (_pending != null) return; + setState(() => _pending = on); + try { + await mutate( + ref, + () => ref.read(apiProvider).updateUser(widget.u.id, sendResults: on), + ); + if (mounted) { + toast(context, on ? 'WhatsApp results on' : 'WhatsApp results off'); + } + } catch (e) { + if (!mounted) return; + setState(() => _pending = null); + toast(context, errText(e)); + } + } + + @override + Widget build(BuildContext context) { + final u = widget.u; + final on = _pending ?? u.sendResults; + final noPhone = u.phone == null; + final note = ts(12, w: w6, c: C.ink); + final Widget? status = noPhone + ? Pressable( + onTap: widget.onAddPhone, + radius: R.sm, + builder: (_, hover) => Text( + 'Add a phone number first', + style: ts( + 12, + w: w6, + c: C.navy, + ).copyWith(decoration: hover ? TextDecoration.underline : null), + ), + ) + : !on + ? null + : !u.active + ? Text('Paused while inactive', style: note) + : null; + return _ToggleRow( + leading: WhatsAppLogo( + size: 20, + color: on && !noPhone && u.active ? null : C.muted, + ), + title: 'WhatsApp results', + description: 'Sends results after each draw.', + status: status, + value: on, + // Turning it on needs a phone number; turning it off never does. + onChanged: _pending != null || (!on && noPhone) ? null : _set, + ); + } +} + +/// A whole-row control: label on top, value below, chevron at the end. +class _SettingRow extends StatelessWidget { + const _SettingRow({ + required this.label, + required this.value, + required this.onTap, + this.muted = false, + this.first = false, + this.last = false, + }); + final String label, value; + final VoidCallback onTap; + final bool muted, first, last; + + @override + Widget build(BuildContext context) => Pressable( + onTap: onTap, + label: 'Change ${label.toLowerCase()}', + radius: 0, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.fromLTRB(S.s16, S.s12, S.s12, S.s12), + decoration: BoxDecoration( + color: hover ? C.hover : Colors.transparent, + borderRadius: BorderRadius.vertical( + top: Radius.circular(first ? R.card : 0), + bottom: Radius.circular(last ? R.card : 0), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: ts(12, w: w6, c: C.muted), + ), + const SizedBox(height: S.s4), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6, c: muted ? C.muted : C.ink), + ), + ], + ), + ), + Text( + 'Change', + style: ts(13, w: w6, c: C.navy), + ), + const SizedBox(width: S.s2), + const Icon(Icons.chevron_right_rounded, color: C.placeholder), + ], + ), + ), + ); +} + +enum _Field { username, phone, password } + +class _EditField extends ConsumerStatefulWidget { + const _EditField({required this.user, required this.field}); + final AppUser user; + final _Field field; + + @override + ConsumerState<_EditField> createState() => _EditFieldState(); +} + +class _EditFieldState extends ConsumerState<_EditField> { + late final _c = TextEditingController( + text: switch (widget.field) { + _Field.username => widget.user.username, + _Field.phone => (widget.user.phone ?? '').replaceFirst( + RegExp(r'^91'), + '', + ), + _Field.password => '', + }, + ); + bool _busy = false; + String? _error; + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _busy = true; + _error = null; + }); + final api = ref.read(apiProvider); + final id = widget.user.id; + try { + await mutate(ref, () { + switch (widget.field) { + case _Field.username: + return api.updateUser(id, username: _c.text); + case _Field.phone: + return _c.text.trim().isEmpty + ? api.updateUser(id, clearPhone: true) + : api.updateUser(id, phone: _c.text); + case _Field.password: + return api.updateUser(id, password: _c.text); + } + }); + if (!mounted) return; + Navigator.pop(context); + toast(context, switch (widget.field) { + _Field.username => 'Username changed', + _Field.phone => 'Phone updated', + _Field.password => 'Password changed', + }); + } catch (e) { + if (mounted) setState(() => _error = errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final f = widget.field; + final title = switch (f) { + _Field.username => 'Change username', + _Field.phone => 'Change phone', + _Field.password => 'Set new password', + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: ts(20, w: w7)), + const SizedBox(height: S.s4), + Text( + f == _Field.password + ? 'Users cannot change their own password. Share the new password with ${widget.user.username}.' + : 'For ${widget.user.username}', + style: ts(13, c: C.muted), + ), + const SizedBox(height: S.s20), + SrsField( + controller: _c, + autofocus: true, + label: switch (f) { + _Field.username => 'Username', + _Field.phone => 'Phone', + _Field.password => 'New password', + }, + hint: f == _Field.phone ? '90000 12345' : null, + prefix: f == _Field.phone ? '+91 ' : null, + password: f == _Field.password, + keyboard: f == _Field.phone ? TextInputType.phone : null, + formatters: f == _Field.phone + ? [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')), + LengthLimitingTextInputFormatter(12), + ] + : null, + helper: f == _Field.password + ? 'At least 8 characters.' + : f == _Field.phone + ? 'Leave blank to remove the phone number.' + : null, + onChanged: (_) => setState(() => _error = null), + onSubmitted: (_) => _save(), + ), + if (_error != null) ...[ + const SizedBox(height: S.s12), + ErrorLine(_error!), + ], + const SizedBox(height: S.s20), + BigButton('Save', loading: _busy, onPressed: _save), + ], + ); + } +} + +class _ShopsEditor extends ConsumerStatefulWidget { + const _ShopsEditor({required this.user, required this.shops}); + final AppUser user; + final List shops; + + @override + ConsumerState<_ShopsEditor> createState() => _ShopsEditorState(); +} + +class _ShopsEditorState extends ConsumerState<_ShopsEditor> { + late final Set _sel = widget.user.shopIds.toSet(); + bool _busy = false; + String? _error; + + Future _save() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await mutate( + ref, + () => ref.read(apiProvider).setUserShops(widget.user.id, _sel.toList()), + ); + if (!mounted) return; + Navigator.pop(context); + toast(context, 'Shops updated'); + } catch (e) { + if (mounted) setState(() => _error = errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Assigned shops', style: ts(20, w: w7)), + const SizedBox(height: S.s4), + Text( + 'This user can only view data for the selected shops.', + style: ts(13, c: C.muted), + ), + const SizedBox(height: S.s18), + ShopPicker( + shops: widget.shops, + selected: _sel, + onToggle: (id) => setState( + () => _sel.contains(id) ? _sel.remove(id) : _sel.add(id), + ), + ), + if (_error != null) ...[ + const SizedBox(height: S.s8), + ErrorLine(_error!), + ], + const SizedBox(height: S.s14), + BigButton('Save', loading: _busy, onPressed: _save), + ], + ); + } +} diff --git a/lib/features/users/user_form_screen.dart b/lib/features/users/user_form_screen.dart new file mode 100644 index 0000000..5aefeef --- /dev/null +++ b/lib/features/users/user_form_screen.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/providers.dart'; +import '../../widgets/ui.dart'; +import 'shop_picker.dart'; +import '../../core/theme.dart'; + +const _maxW = 640.0; + +class UserFormScreen extends ConsumerStatefulWidget { + const UserFormScreen({super.key}); + + @override + ConsumerState createState() => _UserFormScreenState(); +} + +class _UserFormScreenState extends ConsumerState { + final _name = TextEditingController(); + final _phone = TextEditingController(); + final _pass = TextEditingController(); + final _shops = {}; + bool _busy = false; + String? _error; + + @override + void dispose() { + _name.dispose(); + _phone.dispose(); + _pass.dispose(); + super.dispose(); + } + + bool get _ready => _name.text.trim().length >= 3 && _pass.text.length >= 8; + + Future _create() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await mutate( + ref, + () => ref + .read(apiProvider) + .createUser( + username: _name.text, + phone: _phone.text.trim().isEmpty ? null : _phone.text, + password: _pass.text, + shopIds: _shops.toList(), + ), + ); + if (!mounted) return; + context.pop(); + toast(context, 'User created'); + } catch (e) { + if (mounted) setState(() => _error = errText(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final shops = ref.watch(activeShopsProvider); + return Scaffold( + body: SafeArea( + child: Column( + children: [ + const ScreenHeader(title: 'Add user', back: true, maxWidth: _maxW), + Expanded( + child: PageList( + maxWidth: _maxW, + top: S.s8, + children: [ + SrsField( + controller: _name, + label: 'Username', + hint: 'e.g. kzk.staff', + helper: + 'Letters, numbers, dots, dashes and underscores. Used to sign in.', + onChanged: (_) => setState(() => _error = null), + ), + const SizedBox(height: S.s18), + SrsField( + controller: _phone, + label: 'Phone (optional)', + hint: '90000 12345', + prefix: '+91 ', + keyboard: TextInputType.phone, + formatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')), + LengthLimitingTextInputFormatter(12), + ], + onChanged: (_) => setState(() => _error = null), + ), + const SizedBox(height: S.s18), + SrsField( + controller: _pass, + label: 'Password', + hint: 'Enter password', + password: true, + helper: 'At least 8 characters.', + onChanged: (_) => setState(() => _error = null), + ), + const SectionTitle('Shop access'), + asyncView( + shops, + (list) => ShopPicker( + shops: list, + selected: _shops, + onToggle: (id) => setState( + () => _shops.contains(id) + ? _shops.remove(id) + : _shops.add(id), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: S.s8), + ErrorLine(_error!), + ], + const SizedBox(height: S.s22), + BigButton( + 'Create user', + loading: _busy, + onPressed: _ready ? _create : null, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/users/users_screen.dart b/lib/features/users/users_screen.dart new file mode 100644 index 0000000..c4aa4a4 --- /dev/null +++ b/lib/features/users/users_screen.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/format.dart'; +import '../../core/models.dart'; +import '../../core/providers.dart'; +import '../../core/theme.dart'; +import '../../widgets/ui.dart'; +import 'user_bits.dart'; + +enum _Filter { all, active, off } + +class UsersScreen extends ConsumerStatefulWidget { + const UsersScreen({super.key}); + + @override + ConsumerState createState() => _UsersScreenState(); +} + +class _UsersScreenState extends ConsumerState { + _Filter _filter = _Filter.all; + String _q = ''; + + bool _keep(AppUser u) => switch (_filter) { + _Filter.all => true, + _Filter.active => u.active, + _Filter.off => !u.active, + }; + + @override + Widget build(BuildContext context) { + final users = ref.watch(usersProvider); + final shops = + ref.watch(activeShopsProvider).asData?.value ?? const []; + final byId = {for (final s in shops) s.id: s}; + final list = users.asData?.value; + final off = list?.where((u) => !u.active).length ?? 0; + + return Column( + children: [ + ScreenHeader( + title: 'Users', + subtitle: list == null + ? null + : off == 0 + ? plural(list.length, 'user') + : '${plural(list.length, 'user')}, $off inactive', + trailing: SmallButton( + 'Add user', + onTap: () => context.push('/users/new'), + ), + ), + Expanded( + child: asyncView(users, (all) { + final counts = { + _Filter.all: all.length, + _Filter.active: all.where((u) => u.active).length, + _Filter.off: all.where((u) => !u.active).length, + }; + final shown = + all + .where(_keep) + .where( + (u) => + _q.isEmpty || + u.username.toLowerCase().contains(_q) || + (u.phone ?? '').contains(_q), + ) + .toList() + // Inactive accounts last. + ..sort((a, b) { + final r = (a.active ? 0 : 1).compareTo(b.active ? 0 : 1); + return r != 0 ? r : a.username.compareTo(b.username); + }); + return PageList( + top: S.s4, + children: [ + _Toolbar( + filter: _filter, + counts: counts, + onFilter: (f) => setState(() => _filter = f), + onSearch: (v) => setState(() => _q = v.trim().toLowerCase()), + ), + const SizedBox(height: S.s14), + if (shown.isEmpty) + SrsCard( + child: EmptyState( + icon: Icons.person_search_rounded, + title: _q.isNotEmpty + ? 'No one matches “$_q”' + : 'No one here', + body: _filter == _Filter.off + ? 'Every user can sign in.' + : all.isEmpty + ? 'Add a user to let them sign in and see their shops.' + : 'Try another filter, or add a user.', + ), + ) + else + _Roster(users: shown, shops: byId), + ], + ); + }, retry: () => ref.invalidate(usersProvider)), + ), + ], + ); + } +} + +class _Toolbar extends StatelessWidget { + const _Toolbar({ + required this.filter, + required this.counts, + required this.onFilter, + required this.onSearch, + }); + final _Filter filter; + final Map<_Filter, int> counts; + final ValueChanged<_Filter> onFilter; + final ValueChanged onSearch; + + static const _labels = { + _Filter.all: 'All', + _Filter.active: 'Active', + _Filter.off: 'Inactive', + }; + + @override + Widget build(BuildContext context) { + final chips = SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final f in _Filter.values) + if (f == _Filter.all || counts[f]! > 0) + Padding( + padding: const EdgeInsets.only(right: S.s8), + child: _FilterChip( + label: _labels[f]!, + count: counts[f]!, + on: f == filter, + onTap: () => onFilter(f), + ), + ), + ], + ), + ); + final search = SearchField( + hint: 'Search by name or phone', + onChanged: onSearch, + ); + return LayoutBuilder( + builder: (context, c) => c.maxWidth < 760 + ? Column( + children: [ + search, + const SizedBox(height: S.s12), + chips, + ], + ) + : Row( + children: [ + Expanded(child: chips), + const SizedBox(width: S.s16), + SizedBox(width: 320, child: search), + ], + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.count, + required this.on, + required this.onTap, + }); + final String label; + final int count; + final bool on; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + selected: on, + child: Pressable( + onTap: onTap, + radius: R.pill, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 38, + padding: const EdgeInsets.fromLTRB(S.s14, 0, S.s8, 0), + decoration: BoxDecoration( + color: on ? C.navy : (hover ? C.hover : Colors.white), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: on ? C.navy : (hover ? C.border2 : C.border), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: ts(13, w: w6, c: on ? Colors.white : C.ink), + ), + const SizedBox(width: S.s6), + Container( + constraints: const BoxConstraints(minWidth: 22), + height: 22, + padding: const EdgeInsets.symmetric(horizontal: S.s6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? C.onNavy20 : C.chip, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text( + '$count', + style: ts(12, w: w7, c: on ? Colors.white : C.navy), + ), + ), + ], + ), + ), + ), + ); +} + +/// One card holding every row: a table with column headings on wide windows, +/// a compact list on phones. +class _Roster extends StatelessWidget { + const _Roster({required this.users, required this.shops}); + final List users; + final Map shops; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + final wide = c.maxWidth >= 720; + return SrsCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + if (wide) const _HeadingRow(), + for (var i = 0; i < users.length; i++) ...[ + if (i > 0 || wide) const Divider(height: 1, color: C.border), + _UserRow( + u: users[i], + shops: shops, + wide: wide, + first: i == 0 && !wide, + last: i == users.length - 1, + ), + ], + ], + ), + ); + }, + ); +} + +const _phoneCol = 150.0; +const _statusCol = 120.0; + +class _HeadingRow extends StatelessWidget { + const _HeadingRow(); + + @override + Widget build(BuildContext context) { + final h = ts(12, w: w6, c: C.muted); + return Padding( + padding: const EdgeInsets.fromLTRB(S.s20, S.s14, S.s20, S.s12), + child: Row( + children: [ + Expanded(flex: 3, child: Text('Name', style: h)), + SizedBox( + width: _phoneCol, + child: Text('Phone', style: h), + ), + Expanded(flex: 3, child: Text('Shops', style: h)), + SizedBox( + width: _statusCol, + child: Text('Status', style: h), + ), + const SizedBox(width: 28), + ], + ), + ); + } +} + +class _UserRow extends StatelessWidget { + const _UserRow({ + required this.u, + required this.shops, + required this.wide, + required this.first, + required this.last, + }); + final AppUser u; + final Map shops; + final bool wide, first, last; + + @override + Widget build(BuildContext context) { + final mine = [ + for (final id in u.shopIds) + if (shops[id] != null) shops[id]!, + ]; + final shopsCell = ShopCodes(shops: mine); + final name = Row( + children: [ + UserAvatar(user: u, size: 42), + const SizedBox(width: S.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + u.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6, c: u.active ? C.ink : C.muted), + ), + ), + if (u.getsResults) ...[ + const SizedBox(width: S.s6), + const Tooltip( + message: 'Gets results on WhatsApp', + child: WhatsAppLogo(size: 14), + ), + ], + ], + ), + if (!wide) ...[ + const SizedBox(height: S.s4), + Text( + u.phone == null ? 'No phone' : fmtPhone(u.phone), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(12, c: C.muted), + ), + const SizedBox(height: S.s8), + shopsCell, + ], + ], + ), + ), + ], + ); + const trailing = Icon(Icons.chevron_right_rounded, color: C.placeholder); + final radius = BorderRadius.vertical( + top: Radius.circular(first ? R.card : 0), + bottom: Radius.circular(last ? R.card : 0), + ); + + return Pressable( + onTap: () => context.push('/users/${u.id}'), + radius: 0, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: BoxDecoration( + color: hover ? C.hover : Colors.transparent, + borderRadius: radius, + ), + padding: EdgeInsets.symmetric( + horizontal: wide ? S.s20 : S.s14, + vertical: S.s12, + ), + child: wide + ? Row( + children: [ + Expanded(flex: 3, child: name), + SizedBox( + width: _phoneCol, + child: Text( + u.phone == null ? '—' : fmtPhone(u.phone), + style: ts(13, c: u.phone == null ? C.placeholder : C.ink), + ), + ), + Expanded(flex: 3, child: shopsCell), + SizedBox( + width: _statusCol, + child: StatusLabel(active: u.active), + ), + SizedBox(width: 28, child: trailing), + ], + ) + : Row( + children: [ + Expanded(child: name), + const SizedBox(width: S.s8), + trailing, + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..2f554c2 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app.dart'; +import 'core/api.dart'; +import 'core/config.dart'; +import 'core/format.dart'; +import 'core/http_api.dart'; +import 'core/providers.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + if (AppConfig.unconfigured) debugPrint('SRS: ${AppConfig.setupHint}'); + final Api api = HttpApi( + baseUrl: AppConfig.apiBaseUrl, + prefs: await SharedPreferences.getInstance(), + ); + + final user = await api.restore(); + final start = user == null ? dateOnly(api.now) : await _latestOrToday(api); + + runApp( + ProviderScope( + overrides: [ + apiProvider.overrideWithValue(api), + startDateProvider.overrideWithValue(start), + sessionProvider.overrideWith(() => SessionNotifier(user)), + ], + child: const SrsApp(), + ), + ); +} + +Future _latestOrToday(Api api) async { + try { + return await api.latestDrawDate(); + } on ApiException { + return dateOnly(api.now); + } +} diff --git a/lib/widgets/account_sheet.dart b/lib/widgets/account_sheet.dart new file mode 100644 index 0000000..cd1aafe --- /dev/null +++ b/lib/widgets/account_sheet.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/format.dart'; +import '../core/providers.dart'; +import '../core/theme.dart'; +import 'ui.dart'; + +/// Signed-in user and Sign out. A sheet on phones, a dialog on wider windows. +void showAccountSheet(BuildContext context, WidgetRef ref) { + final u = ref.read(sessionProvider); + if (u == null) return; + showSheet(context, (ctx) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar(name: u.username, size: 52), + const SizedBox(width: S.s14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(u.username, style: ts(17, w: w7)), + const SizedBox(height: S.s4), + Row( + children: [ + Pill(u.isAdmin ? 'Admin' : 'User'), + const SizedBox(width: S.s8), + Text( + u.isAdmin + ? 'All shops' + : plural(u.shopIds.length, 'shop'), + style: ts(12, c: C.muted), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: S.s22), + BigButton( + 'Sign out', + outline: true, + icon: Icons.logout_rounded, + onPressed: () { + Navigator.pop(ctx); + ref.read(sessionProvider.notifier).logout(); + }, + ), + ], + ); + }); +} + +/// Initials in a rounded square. +class Avatar extends StatelessWidget { + const Avatar({ + super.key, + required this.name, + this.size = 46, + this.dark = false, + }); + final String name; + final double size; + final bool dark; + + @override + Widget build(BuildContext context) => Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: dark ? C.navy : C.chip, + borderRadius: BorderRadius.circular(size * 0.3), + ), + child: Text( + initials(name), + style: ts(size * 0.33, w: w7, c: dark ? Colors.white : C.navy), + ), + ); +} diff --git a/lib/widgets/main_shell.dart b/lib/widgets/main_shell.dart new file mode 100644 index 0000000..b610b81 --- /dev/null +++ b/lib/widgets/main_shell.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/providers.dart'; +import '../core/theme.dart'; +import 'account_sheet.dart'; +import 'ui.dart'; + +class _Tab { + const _Tab(this.path, this.label, this.icon, this.iconOn); + final String path, label; + final IconData icon, iconOn; +} + +const _tabs = [ + _Tab( + '/results', + 'Results', + Icons.emoji_events_outlined, + Icons.emoji_events_rounded, + ), + _Tab('/shops', 'Shops', Icons.storefront_outlined, Icons.storefront_rounded), + _Tab( + '/orders', + 'Orders', + Icons.receipt_long_outlined, + Icons.receipt_long_rounded, + ), + _Tab('/users', 'Users', Icons.people_outline_rounded, Icons.people_rounded), +]; + +/// App frame. The same sections at every width; only the navigation changes: +/// a tab bar on phones, a rail on tablets, a sidebar on desktop. +class MainShell extends ConsumerWidget { + const MainShell({super.key, required this.location, required this.child}); + final String location; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(sessionProvider); + final tabs = user?.isAdmin == true ? _tabs : _tabs.sublist(0, 3); + final width = MediaQuery.sizeOf(context).width; + final current = tabs.where((t) => location.startsWith(t.path)).firstOrNull; + + if (width >= Bp.medium) { + return Scaffold( + body: Row( + children: [ + _Sidebar( + tabs: tabs, + current: current, + extended: width >= Bp.expanded, + ), + Expanded(child: SafeArea(child: child)), + ], + ), + ); + } + + // Phones: the tab bar stays visible on detail screens too, so people + // always know which section they are in (HIG, Tab bars). + return Scaffold( + body: SafeArea(bottom: false, child: child), + bottomNavigationBar: _BottomBar(tabs: tabs, current: current), + ); + } +} + +// ------------------------------------------------------------ phone tab bar + +class _BottomBar extends StatelessWidget { + const _BottomBar({required this.tabs, required this.current}); + final List<_Tab> tabs; + final _Tab? current; + + @override + Widget build(BuildContext context) => Container( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow(color: C.shadowNav, blurRadius: 20, offset: Offset(0, -4)), + ], + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 72, + child: Row( + children: [ + for (final t in tabs) + Expanded( + child: _TabItem( + tab: t, + active: t == current, + onTap: () => context.go(t.path), + ), + ), + ], + ), + ), + ), + ); +} + +class _TabItem extends StatelessWidget { + const _TabItem({ + required this.tab, + required this.active, + required this.onTap, + }); + final _Tab tab; + final bool active; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Pressable( + onTap: onTap, + radius: R.md, + builder: (_, hover) => Semantics( + selected: active, + // Scales down rather than overflowing while the window has no real + // size yet (Chrome reports a near-zero window for a frame or two). + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 56, + height: 32, + decoration: BoxDecoration( + color: active ? C.chip : (hover ? C.hover : Colors.transparent), + borderRadius: BorderRadius.circular(R.pill), + ), + child: Icon( + active ? tab.iconOn : tab.icon, + size: 22, + color: active ? C.navy : C.muted, + ), + ), + const SizedBox(height: S.s4), + Text( + tab.label, + style: ts(11, w: active ? w7 : w5, c: active ? C.navy : C.muted), + ), + ], + ), + ), + ), + ); +} + +// --------------------------------------------------------- rail and sidebar + +class _Sidebar extends ConsumerWidget { + const _Sidebar({ + required this.tabs, + required this.current, + required this.extended, + }); + final List<_Tab> tabs; + final _Tab? current; + final bool extended; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(sessionProvider); + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + width: extended ? 248 : 88, + decoration: const BoxDecoration( + color: Colors.white, + border: Border(right: BorderSide(color: C.border)), + ), + child: SafeArea( + right: false, + // Scrolls on short windows (a phone in landscape); otherwise the + // account button sits at the bottom. + child: LayoutBuilder( + builder: (context, c) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: c.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: extended ? S.s16 : S.s12, + vertical: S.s20, + ), + child: Column( + crossAxisAlignment: extended + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.center, + children: [ + _Brand(extended: extended), + const SizedBox(height: S.s28), + for (final t in tabs) ...[ + _SideItem( + tab: t, + active: t == current, + extended: extended, + onTap: () => context.go(t.path), + ), + const SizedBox(height: S.s4), + ], + const Spacer(), + if (user != null) + _AccountButton( + name: user.username, + role: user.isAdmin ? 'Admin' : 'User', + extended: extended, + onTap: () => showAccountSheet(context, ref), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// The gold ticket coin from the sign-in screen, carried into the frame. +class _Brand extends StatelessWidget { + const _Brand({required this.extended}); + final bool extended; + + @override + Widget build(BuildContext context) { + final coin = Container( + width: 40, + height: 40, + decoration: const BoxDecoration(color: C.gold, shape: BoxShape.circle), + child: const Icon( + Icons.confirmation_number_rounded, + color: C.navy, + size: 20, + ), + ); + if (!extended) return coin; + return Row( + children: [ + coin, + const SizedBox(width: S.s12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SRS', + style: ts(18, w: w7, c: C.navy, ls: 1.5), + ), + Text( + 'Lottery agency', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(12, c: C.muted), + ), + ], + ), + ), + ], + ); + } +} + +class _SideItem extends StatelessWidget { + const _SideItem({ + required this.tab, + required this.active, + required this.extended, + required this.onTap, + }); + final _Tab tab; + final bool active, extended; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final icon = Icon( + active ? tab.iconOn : tab.icon, + size: 22, + color: active ? C.navy : C.muted, + ); + final label = Text( + tab.label, + maxLines: 1, + style: ts( + extended ? 14 : 11, + w: active ? w7 : w5, + c: active ? C.navy : C.ink, + ), + ); + return Semantics( + selected: active, + child: Pressable( + onTap: onTap, + radius: R.md, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: extended ? 44 : 60, + width: extended ? null : 64, + padding: EdgeInsets.symmetric(horizontal: extended ? S.s12 : 0), + decoration: BoxDecoration( + color: active ? C.chip : (hover ? C.hover : Colors.transparent), + borderRadius: BorderRadius.circular(R.md), + ), + child: extended + ? Row( + children: [ + icon, + const SizedBox(width: S.s12), + Expanded(child: label), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + const SizedBox(height: S.s4), + label, + ], + ), + ), + ), + ); + } +} + +class _AccountButton extends StatelessWidget { + const _AccountButton({ + required this.name, + required this.role, + required this.extended, + required this.onTap, + }); + final String name, role; + final bool extended; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Tooltip( + message: 'Account', + excludeFromSemantics: true, + child: Pressable( + onTap: onTap, + radius: R.md, + label: extended ? null : 'Account, $name', + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.all(S.s8), + decoration: BoxDecoration( + color: hover ? C.hover : Colors.transparent, + borderRadius: BorderRadius.circular(R.md), + border: Border.all(color: extended ? C.border : Colors.transparent), + ), + child: extended + ? Row( + children: [ + Avatar(name: name, size: 36), + const SizedBox(width: S.s10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(13, w: w6), + ), + Text(role, style: ts(12, c: C.muted)), + ], + ), + ), + const Icon( + Icons.unfold_more_rounded, + size: 18, + color: C.placeholder, + ), + ], + ) + : Avatar(name: name, size: 40), + ), + ), + ); +} diff --git a/lib/widgets/ui.dart b/lib/widgets/ui.dart new file mode 100644 index 0000000..25ed052 --- /dev/null +++ b/lib/widgets/ui.dart @@ -0,0 +1,1082 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/format.dart'; +import '../core/providers.dart'; +import '../core/theme.dart'; +import '../features/results/calendar_picker.dart'; + +// ------------------------------------------------------------ pressable + +/// Tap target that also works with a pointer and a keyboard: click cursor, +/// hover state, a focus ring for Tab navigation, and Enter / Space to activate. +class Pressable extends StatefulWidget { + const Pressable({ + super.key, + required this.onTap, + this.child, + this.builder, + this.radius = R.md, + this.label, + }) : assert(child != null || builder != null); + + final VoidCallback? onTap; + final Widget? child; + + /// Builds the content with the current hover state. + final Widget Function(BuildContext context, bool hovered)? builder; + final double radius; + + /// Screen-reader label, needed when the content is only an icon. + final String? label; + + @override + State createState() => _PressableState(); +} + +class _PressableState extends State { + bool _hover = false; + bool _focus = false; + + @override + Widget build(BuildContext context) { + final enabled = widget.onTap != null; + final content = + widget.builder?.call(context, _hover && enabled) ?? widget.child!; + return Semantics( + button: true, + enabled: enabled, + label: widget.label, + child: FocusableActionDetector( + enabled: enabled, + mouseCursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, + onShowHoverHighlight: (v) => setState(() => _hover = v), + onShowFocusHighlight: (v) => setState(() => _focus = v), + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) { + widget.onTap?.call(); + return null; + }, + ), + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: _focus + ? BoxDecoration( + borderRadius: BorderRadius.circular(widget.radius), + border: Border.all(color: C.focus, width: 2), + ) + : const BoxDecoration(), + child: content, + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------- layout + +/// Pads [child] so it lines up with the page column of [maxWidth]. +class PagePad extends StatelessWidget { + const PagePad({ + super.key, + required this.child, + this.maxWidth = Bp.pageMax, + this.top = 0, + this.bottom = 0, + }); + final Widget child; + final double maxWidth, top, bottom; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + final g = pageGutter(c.maxWidth, maxWidth); + return Padding( + padding: EdgeInsets.fromLTRB(g, top, g, bottom), + child: child, + ); + }, + ); +} + +/// Scrolling page whose content sits in a centred column. The scroll bar +/// stays at the window edge. +class PageList extends StatelessWidget { + const PageList({ + super.key, + required this.children, + this.maxWidth = Bp.pageMax, + this.top = S.s4, + this.bottom = S.s28, + }); + final List children; + final double maxWidth, top, bottom; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + final g = pageGutter(c.maxWidth, maxWidth); + return ListView( + padding: EdgeInsets.fromLTRB(g, top, g, bottom), + children: children, + ); + }, + ); +} + +/// Lays cards out in as many columns as fit: one on phones, up to [maxColumns]. +class CardGrid extends StatelessWidget { + const CardGrid({ + super.key, + required this.children, + this.minItemWidth = 340, + this.maxColumns = 3, + this.spacing = S.s12, + this.runSpacing = S.s10, + }); + final List children; + final double minItemWidth, spacing, runSpacing; + final int maxColumns; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + final fit = ((c.maxWidth + spacing) / (minItemWidth + spacing)).floor(); + final cols = fit.clamp(1, maxColumns); + final w = (c.maxWidth - spacing * (cols - 1)) / cols; + return Wrap( + spacing: spacing, + runSpacing: runSpacing, + children: [for (final x in children) SizedBox(width: w, child: x)], + ); + }, + ); +} + +/// A page in two columns on wide layouts. On narrow ones the two lists run +/// one after the other, each item its own list child so the page stays lazy. +class PaneList extends StatelessWidget { + const PaneList({ + super.key, + required this.start, + required this.end, + this.breakpoint = 860, + this.startFlex = 3, + this.endFlex = 2, + this.gap = S.s20, + this.maxWidth = Bp.pageMax, + this.top = S.s4, + }); + final List start, end; + final double breakpoint, gap, maxWidth, top; + final int startFlex, endFlex; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, c) { + final g = pageGutter(c.maxWidth, maxWidth); + final pad = EdgeInsets.fromLTRB(g, top, g, S.s28); + if (c.maxWidth - 2 * g < breakpoint) { + return ListView(padding: pad, children: [...start, ...end]); + } + Widget col(List w) => + Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: w); + return ListView( + padding: pad, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: startFlex, child: col(start)), + SizedBox(width: gap), + Expanded(flex: endFlex, child: col(end)), + ], + ), + ], + ); + }, + ); +} + +bool isWide(BuildContext context) => + MediaQuery.sizeOf(context).width >= Bp.medium; + +// ---------------------------------------------------------------- cards + +class SrsCard extends StatelessWidget { + const SrsCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(S.s16), + this.color = Colors.white, + this.onTap, + this.radius = R.card, + this.borderColor = C.border, + }); + + final Widget child; + final EdgeInsets padding; + final Color color; + final VoidCallback? onTap; + final double radius; + final Color borderColor; + + @override + Widget build(BuildContext context) { + Widget box(bool hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: padding, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: hover ? C.border2 : borderColor), + boxShadow: hover + ? const [ + BoxShadow( + color: C.shadowNav, + blurRadius: 16, + offset: Offset(0, 4), + ), + ] + : const [], + ), + child: child, + ); + if (onTap == null) return box(false); + return Pressable(onTap: onTap, radius: radius, builder: (_, h) => box(h)); + } +} + +class NavyCard extends StatelessWidget { + const NavyCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(S.s22), + }); + final Widget child; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(R.hero), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [C.navy, C.navyDeep], + ), + boxShadow: const [ + BoxShadow(color: C.shadowNavy, blurRadius: 24, offset: Offset(0, 10)), + ], + ), + child: child, + ); +} + +class CodeTile extends StatelessWidget { + const CodeTile(this.code, {super.key, this.size = 46, this.dark = false}); + final String code; + final double size; + final bool dark; + + @override + Widget build(BuildContext context) => Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: dark ? C.navy : C.chip, + borderRadius: BorderRadius.circular(size * 0.3), + ), + child: Text( + code.length > 4 ? code.substring(0, 4) : code, + style: ts(size * 0.28, w: w7, c: dark ? Colors.white : C.navy), + ), + ); +} + +class Pill extends StatelessWidget { + const Pill( + this.text, { + super.key, + this.bg = C.chip, + this.fg = C.navy, + this.icon, + }); + final String text; + final Color bg, fg; + final IconData? icon; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: S.s10, vertical: S.s4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: fg), + const SizedBox(width: S.s4), + ], + Text( + text, + style: ts(11, w: w6, c: fg), + ), + ], + ), + ); +} + +// -------------------------------------------------------------- buttons + +class BigButton extends StatelessWidget { + const BigButton( + this.label, { + super.key, + required this.onPressed, + this.loading = false, + this.danger = false, + this.outline = false, + this.icon, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading, danger, outline; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final fg = outline ? (danger ? C.red : C.navy) : Colors.white; + final bg = outline ? Colors.transparent : (danger ? C.red : C.navy); + final enabled = onPressed != null && !loading; + return Opacity( + opacity: onPressed == null && !loading ? 0.45 : 1, + child: Pressable( + onTap: enabled ? onPressed : null, + radius: R.button, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 54, + padding: const EdgeInsets.symmetric(horizontal: S.s16), + alignment: Alignment.center, + decoration: BoxDecoration( + color: hover + ? (outline + ? (danger ? C.redTint : C.chip) + : (danger ? C.redDeep : C.navyDeep)) + : bg, + borderRadius: BorderRadius.circular(R.button), + border: outline + ? Border.all(color: danger ? C.red : C.border2, width: 1.5) + : null, + ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.4, color: fg), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: fg), + const SizedBox(width: S.s8), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(15, w: w6, c: fg), + ), + ), + ], + ), + ), + ), + ); + } +} + +class SmallButton extends StatelessWidget { + const SmallButton( + this.label, { + super.key, + required this.onTap, + this.icon = Icons.add_rounded, + }); + final String label; + final VoidCallback onTap; + final IconData icon; + + @override + Widget build(BuildContext context) => Pressable( + onTap: onTap, + radius: R.pill, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 40, + padding: const EdgeInsets.fromLTRB(S.s12, 0, S.s16, 0), + decoration: BoxDecoration( + color: hover ? C.navyDeep : C.navy, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: Colors.white), + const SizedBox(width: S.s4), + Text( + label, + style: ts(13, w: w6, c: Colors.white), + ), + ], + ), + ), + ); +} + +class RoundIcon extends StatelessWidget { + const RoundIcon( + this.icon, { + super.key, + required this.onTap, + required this.label, + this.size = 42, + this.dark = false, + }); + final IconData icon; + final VoidCallback onTap; + + /// Tooltip and screen-reader label; the button shows only an icon. + final String label; + final double size; + final bool dark; + + @override + Widget build(BuildContext context) => Tooltip( + message: label, + excludeFromSemantics: true, + child: Pressable( + onTap: onTap, + label: label, + radius: size / 2, + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: size, + height: size, + decoration: BoxDecoration( + color: dark + ? (hover ? C.navyDeep : C.navy) + : (hover ? C.chip : Colors.white), + shape: BoxShape.circle, + border: dark ? null : Border.all(color: hover ? C.border2 : C.border), + ), + child: Icon(icon, size: 20, color: dark ? Colors.white : C.navy), + ), + ), + ); +} + +// --------------------------------------------------------------- fields + +class SrsField extends StatefulWidget { + const SrsField({ + super.key, + required this.controller, + required this.label, + this.hint, + this.helper, + this.password = false, + this.prefix, + this.keyboard, + this.onChanged, + this.onSubmitted, + this.enabled = true, + this.autofocus = false, + this.formatters, + this.error = false, + this.textCapitalization = TextCapitalization.none, + this.autofill, + }); + + final TextEditingController controller; + final String label; + final String? hint, helper; + final bool password, enabled, autofocus, error; + final String? prefix; + final TextInputType? keyboard; + final ValueChanged? onChanged, onSubmitted; + final List? formatters; + final TextCapitalization textCapitalization; + final List? autofill; + + @override + State createState() => _SrsFieldState(); +} + +class _SrsFieldState extends State { + bool _hide = true; + + OutlineInputBorder _b(Color c, [double w = 1]) => OutlineInputBorder( + borderRadius: BorderRadius.circular(R.input), + borderSide: BorderSide(color: c, width: w), + ); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: ts(12, w: w6, c: C.muted), + ), + const SizedBox(height: S.s6), + TextField( + controller: widget.controller, + enabled: widget.enabled, + autofocus: widget.autofocus, + obscureText: widget.password && _hide, + keyboardType: widget.keyboard, + inputFormatters: widget.formatters, + textCapitalization: widget.textCapitalization, + autofillHints: widget.autofill, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + style: ts(15, w: w5), + decoration: InputDecoration( + filled: true, + fillColor: widget.enabled ? Colors.white : C.fieldDisabled, + hintText: widget.hint, + hintStyle: ts(15, c: C.placeholder), + prefixText: widget.prefix, + prefixStyle: ts(15, w: w6, c: C.muted), + contentPadding: const EdgeInsets.symmetric( + horizontal: S.s16, + vertical: S.s16, + ), + border: _b(widget.error ? C.red : C.border2), + enabledBorder: _b(widget.error ? C.red : C.border2), + disabledBorder: _b(C.border), + focusedBorder: _b(widget.error ? C.red : C.navy, 1.6), + suffixIcon: widget.password + ? IconButton( + tooltip: _hide ? 'Show password' : 'Hide password', + onPressed: () => setState(() => _hide = !_hide), + icon: Icon( + _hide + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + size: 20, + color: C.muted, + ), + ) + : null, + ), + ), + if (widget.helper != null) ...[ + const SizedBox(height: S.s6), + Text(widget.helper!, style: ts(12, c: C.muted)), + ], + ], + ); + } +} + +class SearchField extends StatelessWidget { + const SearchField({super.key, required this.hint, required this.onChanged}); + final String hint; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => TextField( + onChanged: onChanged, + style: ts(14), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + hintText: hint, + hintStyle: ts(14, c: C.placeholder), + prefixIcon: const Icon( + Icons.search_rounded, + color: C.placeholder, + size: 20, + ), + contentPadding: const EdgeInsets.symmetric(vertical: S.s14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.input), + borderSide: const BorderSide(color: C.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.input), + borderSide: const BorderSide(color: C.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(R.input), + borderSide: const BorderSide(color: C.navy, width: 1.4), + ), + ), + ); +} + +class ErrorLine extends StatelessWidget { + const ErrorLine(this.text, {super.key}); + final String text; + @override + Widget build(BuildContext context) => Row( + children: [ + const Icon(Icons.error_outline_rounded, size: 16, color: C.red), + const SizedBox(width: S.s6), + Expanded( + child: Text( + text, + style: ts(13, w: w5, c: C.red), + ), + ), + ], + ); +} + +// -------------------------------------------------------------- headers + +class ScreenHeader extends StatelessWidget { + const ScreenHeader({ + super.key, + required this.title, + this.trailing, + this.back = false, + this.subtitle, + this.maxWidth = Bp.pageMax, + }); + final String title; + final String? subtitle; + final Widget? trailing; + final bool back; + + /// Width of the page column the header lines up with. + final double maxWidth; + + @override + Widget build(BuildContext context) { + final wide = isWide(context); + return PagePad( + maxWidth: maxWidth, + top: wide ? S.s28 : S.s14, + bottom: wide ? S.s14 : S.s8, + child: _row(wide, context), + ); + } + + Widget _row(bool wide, BuildContext context) => Row( + children: [ + if (back) ...[ + RoundIcon( + Icons.arrow_back_rounded, + label: 'Back', + onTap: () => + context.canPop() ? context.pop() : context.go('/results'), + ), + const SizedBox(width: S.s12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts( + back ? (wide ? 24 : 20) : (wide ? 30 : 26), + w: w7, + ls: -0.4, + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: S.s2), + child: Text(subtitle!, style: ts(13, c: C.muted)), + ), + ], + ), + ), + ?trailing, + ], + ); +} + +class SectionTitle extends StatelessWidget { + const SectionTitle(this.text, {super.key, this.trailing}); + final String text; + final String? trailing; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(S.s2, S.s22, S.s2, S.s10), + child: Row( + children: [ + Expanded( + child: Text(text, style: ts(17, w: w7)), + ), + if (trailing != null) Text(trailing!, style: ts(12, c: C.muted)), + ], + ), + ); +} + +class StatCard extends StatelessWidget { + const StatCard({ + super.key, + required this.label, + required this.value, + this.valueColor = C.ink, + this.tile, + }); + final String label, value; + final Color valueColor; + final String? tile; + + @override + Widget build(BuildContext context) => SrsCard( + padding: const EdgeInsets.all(S.s16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: ts(11, w: w6, c: C.muted, ls: 0.8), + ), + const SizedBox(height: S.s8), + Row( + children: [ + if (tile != null) ...[ + CodeTile(tile!, size: 30), + const SizedBox(width: S.s8), + ], + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ts(22, w: w7, c: valueColor, ls: -0.3), + ), + ), + ], + ), + ], + ), + ); +} + +// ------------------------------------------------------- async + states + +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.body, + }); + final IconData icon; + final String title; + final String? body; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: S.s36, horizontal: S.s24), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: C.chip, + shape: BoxShape.circle, + ), + child: Icon(icon, color: C.navy, size: 28), + ), + const SizedBox(height: S.s16), + Text( + title, + textAlign: TextAlign.center, + style: ts(16, w: w7), + ), + if (body != null) ...[ + const SizedBox(height: S.s6), + Text( + body!, + textAlign: TextAlign.center, + style: ts(13, c: C.muted, h: 1.45), + ), + ], + ], + ), + ); +} + +Widget asyncView( + AsyncValue v, + Widget Function(T data) builder, { + VoidCallback? retry, +}) { + return v.when( + skipLoadingOnReload: true, + data: builder, + loading: () => const Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 3, color: C.navy), + ), + ), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(S.s28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_rounded, size: 36, color: C.muted), + const SizedBox(height: S.s12), + Text( + e is Exception ? e.toString() : 'Something went wrong.', + textAlign: TextAlign.center, + style: ts(14, c: C.muted), + ), + if (retry != null) ...[ + const SizedBox(height: S.s14), + TextButton( + onPressed: retry, + child: Text( + 'Retry', + style: ts(14, w: w6, c: C.navy), + ), + ), + ], + ], + ), + ), + ), + ); +} + +// ------------------------------------------------------ sheets & dialogs + +Future showSheet( + BuildContext context, + Widget Function(BuildContext) builder, +) { + if (isWide(context)) { + // Tablet and desktop: a centred dialog instead of a full-width sheet. + return showDialog( + context: context, + useRootNavigator: true, + builder: (ctx) => Dialog( + insetPadding: const EdgeInsets.all(S.s24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(S.s28), + child: builder(ctx), + ), + Positioned( + top: S.s12, + right: S.s12, + child: IconButton( + tooltip: 'Close', + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close_rounded, color: C.muted), + ), + ), + ], + ), + ), + ), + ); + } + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + useRootNavigator: true, + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(S.s22, S.s10, S.s22, S.s24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: S.s18), + decoration: BoxDecoration( + color: C.border2, + borderRadius: BorderRadius.circular(R.sm), + ), + ), + ), + builder(ctx), + ], + ), + ), + ), + ); +} + +Future confirm( + BuildContext context, { + required String title, + required String body, + required String confirmLabel, + bool danger = true, +}) async { + final r = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(S.s24, S.s22, S.s24, S.s8), + actionsPadding: const EdgeInsets.fromLTRB(S.s16, S.s4, S.s16, S.s14), + title: null, + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: danger ? C.redTint : C.chip, + shape: BoxShape.circle, + ), + child: Icon( + danger + ? Icons.delete_outline_rounded + : Icons.help_outline_rounded, + color: danger ? C.red : C.navy, + size: 24, + ), + ), + const SizedBox(height: S.s16), + Text(title, style: ts(18, w: w7)), + const SizedBox(height: S.s8), + Text(body, style: ts(14, c: C.muted, h: 1.5)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text( + 'Cancel', + style: ts(14, w: w6, c: C.muted), + ), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + confirmLabel, + style: ts(14, w: w7, c: danger ? C.red : C.navy), + ), + ), + ], + ), + ); + return r ?? false; +} + +void toast(BuildContext context, String msg) { + final m = ScaffoldMessenger.of(context); + m.hideCurrentSnackBar(); + m.showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 3)), + ); +} + +String errText(Object e) => + e is Exception ? e.toString() : 'Something went wrong. Please try again.'; + +// ----------------------------------------------------------- date strip + +class DateStrip extends ConsumerWidget { + const DateStrip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final d = ref.watch(selectedDateProvider); + final n = ref.read(selectedDateProvider.notifier); + return Row( + children: [ + RoundIcon( + Icons.chevron_left_rounded, + label: 'Previous day', + size: 40, + onTap: () => n.set(addDays(d, -1)), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: S.s10), + child: Builder( + builder: (anchor) => Pressable( + onTap: () => showCalendarPicker(anchor), + radius: R.pill, + label: 'Choose date', + builder: (_, hover) => AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: hover ? C.hover : Colors.white, + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: hover ? C.border2 : C.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.calendar_month_rounded, + size: 16, + color: C.navy, + ), + const SizedBox(width: S.s8), + Text( + dateLong(d), + style: ts(13, w: w6, c: C.navy), + ), + ], + ), + ), + ), + ), + ), + ), + RoundIcon( + Icons.chevron_right_rounded, + label: 'Next day', + size: 40, + onTap: () => n.set(addDays(d, 1)), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..2edf2a8 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,559 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "100c19dcc4ff60083e95a44a6f44f3a1bb887d353c5d86f6a9b688aa3cb46236" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + 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" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f1c2d3c2b0b2096b435d7af033dd4e586fa31f51d988c5985e64495cd926d872 + url: "https://pub.dev" + source: hosted + version: "18.1.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: "2e91e6087291affd10b31b6c872f3e3ac7dc5d93b51b5ba485d930c8f08bde6c" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: fbfb53cab6c4629438feeade5f3d305f72944bc9d9f25786b19106d32b80ec45 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a" + url: "https://pub.dev" + source: hosted + version: "3.4.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "82885f6ac633044e7a4e4d8d745724a67aa0dc50af5d8a81ec29379f63c29035" + url: "https://pub.dev" + source: hosted + version: "1.2.6" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4c9d2e7121eea8b4f160e6e435900a0ae7214cfc97c2d6cf84c25532b789b50d" + url: "https://pub.dev" + source: hosted + version: "1.3.4" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..98a7cd7 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,104 @@ +name: srs_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=3.8.0 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.4.3 + go_router: ^18.1.2 + http: ^1.6.0 + shared_preferences: ^2.5.6 + flutter_svg: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # 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: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + uses-material-design: true + assets: + - assets/icons/whatsapp.svg + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.otf + weight: 400 + - asset: assets/fonts/Inter-Medium.otf + weight: 500 + - asset: assets/fonts/Inter-SemiBold.otf + weight: 600 + - asset: assets/fonts/Inter-Bold.otf + weight: 700 + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..4cb41a2 --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:srs_app/app.dart'; +import 'support/fake_api.dart'; +import 'package:srs_app/core/providers.dart'; + +Future boot(WidgetTester t) async { + t.view.physicalSize = const Size(430 * 2, 900 * 2); + t.view.devicePixelRatio = 2; + addTearDown(t.view.reset); + await t.pumpWidget( + ProviderScope( + overrides: [ + apiProvider.overrideWithValue(FakeApi()), + startDateProvider.overrideWithValue(DateTime(2026, 10, 16)), + ], + child: const SrsApp(), + ), + ); + await t.pumpAndSettle(); +} + +Future signIn(WidgetTester t, String u, String p) async { + await t.enterText(find.byType(TextField).at(0), u); + await t.enterText(find.byType(TextField).at(1), p); + await t.tap(find.text('Sign in').last); + await t.pumpAndSettle(const Duration(milliseconds: 400)); +} + +void main() { + testWidgets('wrong password shows the error line', (t) async { + await boot(t); + expect(find.text('Welcome back'), findsOneWidget); + expect( + find.text('Forgot your password? Contact your administrator.'), + findsOneWidget, + ); + await signIn(t, 'owner', 'bad-password'); + expect(find.text('Wrong username or password'), findsOneWidget); + }); + + testWidgets('admin sees results, four tabs and can open a shop', (t) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + expect(find.text('₹7,700'), findsOneWidget); + expect(find.text('Suvarna Keralam · SK-74'), findsOneWidget); + expect(find.text('Users'), findsOneWidget); + await t.tap(find.text('Shops').last); + await t.pumpAndSettle(); + expect(find.text('Add shop'), findsOneWidget); + expect(find.text('180 tickets · 2 orders'), findsOneWidget); + }); + + testWidgets('normal user has three tabs and no Add shop button', (t) async { + await boot(t); + await signIn(t, 'kzk.staff', 'staff123'); + expect(find.text('₹6,300'), findsOneWidget); + expect(find.text('Users'), findsNothing); + await t.tap(find.text('Shops').last); + await t.pumpAndSettle(); + expect(find.text('Add shop'), findsNothing); + }); + + testWidgets('order detail lets a user delete the order after confirming', ( + t, + ) async { + await boot(t); + await signIn(t, 'kzk.staff', 'staff123'); + await t.tap(find.text('Orders').last); + await t.pumpAndSettle(); + expect(find.text('Orders'), findsWidgets); + await t.tap(find.textContaining('Photo').first); + await t.pumpAndSettle(); + await t.scrollUntilVisible( + find.text('Delete order'), + 400, + scrollable: find.byType(Scrollable).first, + ); + await t.tap(find.text('Delete order')); + await t.pumpAndSettle(); + expect(find.text('Delete this order?'), findsOneWidget); + await t.tap(find.text('Delete order').last); + await t.pumpAndSettle(const Duration(milliseconds: 500)); + expect(find.text('Delete this order?'), findsNothing); + }); + + testWidgets('admin: user toggle and delete both ask for confirmation', ( + t, + ) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + await t.tap(find.text('Users').last); + await t.pumpAndSettle(); + await t.tap(find.text('skm.staff')); + await t.pumpAndSettle(); + await t.tap(find.byType(Switch).first); + await t.pumpAndSettle(); + expect(find.text('Deactivate skm.staff?'), findsOneWidget); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(t.widget(find.byType(Switch).first).value, isTrue); + // To the end of the page, clear of the tab bar. + await t.drag(find.byType(Scrollable).first, const Offset(0, -600)); + await t.pumpAndSettle(); + await t.tap(find.text('Delete user')); + await t.pumpAndSettle(); + expect(find.text('Delete skm.staff?'), findsOneWidget); + await t.tap(find.text('Delete user').last); + await t.pumpAndSettle(const Duration(milliseconds: 500)); + expect(find.text('skm.staff'), findsNothing); + }); + + testWidgets('admin: results messages switch, and no phone means no switch', ( + t, + ) async { + await boot(t); + await signIn(t, 'owner', 'admin123'); + await t.tap(find.text('Users').last); + await t.pumpAndSettle(); + expect(find.byTooltip('Gets results on WhatsApp'), findsOneWidget); + + await t.tap(find.text('kzk.staff')); + await t.pumpAndSettle(); + final results = find.byType(Switch).last; + await t.scrollUntilVisible( + find.text('WhatsApp results'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(t.widget(results).value, isTrue); + expect(find.text('+91 90000 11111'), findsWidgets); + await t.tap(results); + await t.pumpAndSettle(const Duration(milliseconds: 400)); + expect(find.text('WhatsApp results off'), findsOneWidget); + expect(t.widget(find.byType(Switch).last).value, isFalse); + + await t.tap(find.byTooltip('Back')); + await t.pumpAndSettle(const Duration(milliseconds: 400)); + expect(find.byTooltip('Gets results on WhatsApp'), findsNothing); + await t.tap(find.text('skm.staff')); + await t.pumpAndSettle(); + await t.scrollUntilVisible( + find.text('Add a phone number first'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(t.widget(find.byType(Switch).last).onChanged, isNull); + }); +} diff --git a/test/core_test.dart b/test/core_test.dart new file mode 100644 index 0000000..acc42c9 --- /dev/null +++ b/test/core_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:srs_app/core/api.dart'; +import 'support/fake_api.dart'; +import 'package:srs_app/core/format.dart'; + +void main() { + group('format', () { + test('Indian digit grouping', () { + expect(inr(0), '0'); + expect(inr(999), '999'); + expect(inr(1000), '1,000'); + expect(inr(7700), '7,700'); + expect(inr(519900), '5,19,900'); + expect(inr(12345678), '1,23,45,678'); + expect(rupees(3700), '₹3,700'); + }); + + test('dates and times', () { + expect(dateLong(DateTime(2026, 10, 16)), 'Fri, 16 Oct 2026'); + expect(dateShort(DateTime(2026, 10, 16)), 'Fri, 16 Oct'); + expect(time12(DateTime(2026, 10, 16, 12, 41)), '12:41 PM'); + expect(time12(DateTime(2026, 10, 16, 0, 5)), '12:05 AM'); + expect(fmtPhone('919000012345'), '+91 90000 12345'); + }); + + test('server instants are shown on the Indian clock', () { + expect( + istWallClock(DateTime.utc(2026, 10, 16, 5, 30)), + DateTime(2026, 10, 16, 11, 0), + ); + expect( + istWallClock(DateTime.utc(2026, 10, 16, 20, 0)), + DateTime(2026, 10, 17, 1, 30), + ); + expect(parseDate('2026-10-16'), DateTime(2026, 10, 16)); + }); + + test('prize tiers read naturally', () { + expect(tiersLabel([5]), '5th prize'); + expect(tiersLabel([8, 5]), '5th + 8th prize'); + expect(tiersLabel([]), ''); + }); + }); + + group('FakeApi fixture (SK-74, Fri 16 Oct 2026)', () { + final d = DateTime(2026, 10, 16); + + test('admin sees the design numbers', () async { + final api = FakeApi(); + await api.login('owner', 'admin123'); + final r = await api.results(d); + expect(r.draw.published, isTrue); + expect(r.draw.label, 'Suvarna Keralam · SK-74'); + expect(r.totalPrize, 7700); + expect(r.winningTickets, 16); + expect(r.totalTickets, 500); + expect(r.orders, 6); + expect(r.winningShops, 4); + expect(r.topShop!.shop.code, 'KZK'); + expect(r.topShop!.prize, 3700); + expect(r.topShop!.winTickets, 10); + }); + + test('KZK detail: 180 tickets, 52 endings, 2 orders', () async { + final api = FakeApi(); + await api.login('owner', 'admin123'); + final shops = await api.shops(d); + final kzk = shops.firstWhere((s) => s.shop.code == 'KZK'); + expect(kzk.tickets, 180); + expect(kzk.endings, 52); + expect(kzk.orders, 2); + final det = await api.shopDetail(kzk.shop.id, d); + expect(det.prize, 3700); + expect(det.wins.map((w) => w.ending).toSet(), { + '0376', + '0987', + '1360', + '2564', + }); + }); + + test('results for tomorrow are not out; counts only', () async { + final api = FakeApi(); + await api.login('owner', 'admin123'); + final r = await api.results(DateTime(2026, 10, 17)); + expect(r.draw.published, isFalse); + expect(r.totalPrize, 0); + expect(r.totalTickets, 30); + }); + }); + + group('roles', () { + final d = DateTime(2026, 10, 16); + + test('user only sees assigned shops', () async { + final api = FakeApi(); + await api.login('kzk.staff', 'staff123'); + final r = await api.results(d); + expect(r.shops.map((s) => s.shop.code).toSet(), {'KZK', 'SKM'}); + expect(r.totalPrize, 3700 + 2600); + final shops = await api.shops(d); + expect(shops.length, 2); + expect(() => api.shopDetail(3, d), throwsA(isA())); + }); + + test('user cannot manage shops or users', () async { + final api = FakeApi(); + await api.login('kzk.staff', 'staff123'); + expect(() => api.createShop('X', 'XXX'), throwsA(isA())); + expect(() => api.users(), throwsA(isA())); + }); + + test('wrong password and inactive users cannot sign in', () async { + final api = FakeApi(); + expect(() => api.login('owner', 'nope'), throwsA(isA())); + expect( + () => api.login('ptm.staff', 'staff123'), + throwsA(isA()), + ); + }); + }); + + group('editing', () { + final d = DateTime(2026, 10, 16); + + test( + 'changing a quantity updates totals and marks the order edited', + () async { + final api = FakeApi(); + await api.login('owner', 'admin123'); + final orders = await api.orders(d); + final ptm = orders.firstWhere((o) => o.shop.code == 'PTM'); + await api.setLineQty(ptm.id, '3821', 3); + final r = await api.results(d); + expect(r.totalPrize, 7700 + 400); + expect((await api.order(ptm.id)).edited, isTrue); + }, + ); + + test( + 'removing the last ending deletes the order; cancel removes tickets', + () async { + final api = FakeApi(); + await api.login('owner', 'admin123'); + final orders = await api.orders(DateTime(2026, 10, 17)); + final one = orders.firstWhere((o) => o.shop.code == 'KZK'); + for (final l in one.lines) { + await api.removeLine(one.id, l.ending); + } + expect(() => api.order(one.id), throwsA(isA())); + final ulr = orders.firstWhere((o) => o.shop.code == 'ULR'); + await api.cancelOrder(ulr.id); + expect((await api.orders(DateTime(2026, 10, 17))), isEmpty); + }, + ); + + test('shop and user rules', () async { + final d = DateTime(2026, 10, 16); + final api = FakeApi(); + await api.login('owner', 'admin123'); + expect( + () => api.createShop('Another', 'KZK'), + throwsA(isA()), + ); + final s = await api.createShop('Medical College', 'MCH'); + await api.renameShop(s.id, 'Medical College Jn'); + await api.setShopActive(s.id, false); + expect((await api.activeShops()).any((x) => x.id == s.id), isFalse); + expect( + (await api.shops(d)).any((x) => x.shop.id == s.id && !x.shop.active), + isTrue, + ); + await api.setShopActive(s.id, true); + expect((await api.activeShops()).any((x) => x.id == s.id), isTrue); + + expect( + () => + api.createUser(username: 'ab', password: 'longenough', shopIds: []), + throwsA(isA()), + ); + expect( + () => + api.createUser(username: 'newuser', password: 'short', shopIds: []), + throwsA(isA()), + ); + final u = await api.createUser( + username: 'newuser', + phone: '98765 43210', + password: 'longenough', + shopIds: [1], + ); + expect(u.phone, '919876543210'); + await api.updateUser(u.id, active: false); + await api.deleteUser(u.id); + expect((await api.users()).any((x) => x.id == u.id), isFalse); + expect(() => api.deleteUser(1), throwsA(isA())); + final u2 = await api.createUser( + username: 'second', + password: 'longenough', + shopIds: [], + ); + await api.updateUser(u2.id, active: false); + api.logout(); + expect( + () => api.login('second', 'longenough'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/http_api_test.dart b/test/http_api_test.dart new file mode 100644 index 0000000..e68fd3e --- /dev/null +++ b/test/http_api_test.dart @@ -0,0 +1,343 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:srs_app/core/api.dart'; +import 'package:srs_app/core/http_api.dart'; + +const _user = { + 'id': 7, + 'username': 'kzk.staff', + 'phone': '919847012345', + 'isAdmin': false, + 'active': true, + 'shopIds': [1, 2], +}; + +const _shop = {'id': 1, 'code': 'KZK', 'name': 'Kozhikode', 'active': true}; + +const _order = { + 'id': 11, + 'shop': _shop, + 'drawDate': '2026-10-16', + 'createdAt': '2026-10-16T05:30:00Z', + 'edited': true, + 'isPhoto': false, + 'rawText': '1234-2', + 'lines': [ + { + 'ending': '1234', + 'qty': 2, + 'hits': [ + {'tier': 5, 'amount': 1000}, + {'tier': 8, 'amount': 100}, + ], + }, + {'ending': '5678', 'qty': 1, 'hits': []}, + ], +}; + +http.Response _json(Object body, [int status = 200]) => http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, +); + +Future<(HttpApi, List, SharedPreferences)> _api( + http.Response Function(http.Request) reply, { + Map prefs = const {}, +}) async { + SharedPreferences.setMockInitialValues(prefs); + final store = await SharedPreferences.getInstance(); + final seen = []; + final api = HttpApi( + baseUrl: 'https://srs.test/', + prefs: store, + client: MockClient((r) async { + seen.add(r); + return reply(r); + }), + ); + return (api, seen, store); +} + +void main() { + group('signing in', () { + test('keeps the token and sends it on later calls', () async { + final (api, seen, store) = await _api( + (r) => r.url.path == '/api/auth/login' + ? _json({'token': 'abc', 'user': _user}) + : _json({'date': '2026-10-16'}), + ); + final u = await api.login(' kzk.staff ', 'secret-pass'); + expect(u.username, 'kzk.staff'); + expect(u.shopIds, [1, 2]); + expect(jsonDecode(seen.single.body), { + 'username': 'kzk.staff', + 'password': 'secret-pass', + }); + expect(seen.single.headers['authorization'], isNull); + expect(store.getString('token'), 'abc'); + + expect(await api.latestDrawDate(), DateTime(2026, 10, 16)); + expect(seen.last.url.toString(), 'https://srs.test/api/draws/latest'); + expect(seen.last.headers['authorization'], 'Bearer abc'); + }); + + test('shows the server message for a wrong password', () async { + final (api, _, store) = await _api( + (_) => _json({ + 'error': 'invalid_credentials', + 'message': 'Incorrect username or password.', + }, 401), + ); + await expectLater( + api.login('a', 'b'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Incorrect username or password.', + ), + ), + ); + expect(store.getString('token'), isNull); + }); + + test('restores the user from a saved token, or none without one', () async { + final (none, calls, _) = await _api((_) => _json(_user)); + expect(await none.restore(), isNull); + expect(calls, isEmpty); + + final (api, _, _) = await _api( + (_) => _json(_user), + prefs: {'token': 'abc'}, + ); + expect((await api.restore())!.id, 7); + }); + + test('forgets an old token the server no longer accepts', () async { + final (api, _, store) = await _api( + (_) => _json({'error': 'unauthorized', 'message': 'Expired.'}, 401), + prefs: {'token': 'old'}, + ); + expect(await api.restore(), isNull); + expect(store.getString('token'), isNull); + }); + + test('logout drops the token', () async { + final (api, _, store) = await _api( + (_) => _json(_user), + prefs: {'token': 'abc'}, + ); + api.logout(); + await Future.delayed(Duration.zero); + expect(store.getString('token'), isNull); + }); + }); + + group('a session that ends', () { + test('clears the token and tells the app', () async { + final (api, _, store) = await _api( + (_) => _json({'error': 'unauthorized', 'message': 'Ended.'}, 401), + prefs: {'token': 'abc'}, + ); + final ended = expectLater(api.sessionEnded, emits(anything)); + await expectLater( + api.orders(DateTime(2026, 10, 16)), + throwsA(isA()), + ); + await ended; + expect(store.getString('token'), isNull); + }); + }); + + group('failures', () { + test('an unreachable server gives a plain message', () async { + SharedPreferences.setMockInitialValues({}); + final api = HttpApi( + baseUrl: 'https://srs.test', + prefs: await SharedPreferences.getInstance(), + client: MockClient((_) async => throw http.ClientException('down')), + ); + await expectLater( + api.activeShops(), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Unable to reach the server'), + ), + ), + ); + }); + + test('a server error without a message is not shown raw', () async { + final (api, _, _) = await _api((_) => http.Response('', 502)); + await expectLater( + api.activeShops(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Something went wrong on the server. Try again shortly.', + ), + ), + ); + }); + }); + + group('reading', () { + test( + 'turns an order into models in Indian time with every prize', + () async { + final (api, seen, _) = await _api((_) => _json(_order)); + final o = await api.order(11); + expect(seen.single.url.path, '/api/orders/11'); + expect(o.time, DateTime(2026, 10, 16, 11, 0)); + expect(o.drawDate, DateTime(2026, 10, 16)); + expect(o.edited, isTrue); + expect(o.lines.first.tierText, '5th + 8th prize'); + expect(o.lines.first.perTicket, 1100); + expect(o.prize, 2200); + expect(o.lines.last.wins, isFalse); + }, + ); + + test('reads a day of results, an awaited draw included', () async { + final (api, seen, _) = await _api( + (_) => _json({ + 'draw': { + 'date': '2026-10-17', + 'scheme': null, + 'code': null, + 'published': false, + 'hasWin': false, + }, + 'shops': [ + { + 'shop': _shop, + 'orders': 2, + 'tickets': 9, + 'winTickets': 0, + 'prize': 0, + }, + ], + }), + ); + final r = await api.results(DateTime(2026, 10, 17)); + expect(seen.single.url.path, '/api/draws/2026-10-17/results'); + expect(r.draw.published, isFalse); + expect(r.draw.label, 'No result yet'); + expect(r.shops.single.tickets, 9); + }); + + test('asks for a month and a shop day with the right addresses', () async { + final (api, seen, _) = await _api( + (r) => r.url.path == '/api/draws' + ? _json({'days': []}) + : _json({ + 'shop': _shop, + 'draw': { + 'date': '2026-10-16', + 'scheme': 'Suvarna Keralam', + 'code': 'SK-74', + 'published': true, + 'hasWin': true, + }, + 'endings': [], + 'orders': [_order], + }), + ); + await api.month(2026, 3); + expect( + seen.last.url.toString(), + 'https://srs.test/api/draws?month=2026-03', + ); + final d = await api.shopDetail(1, DateTime(2026, 10, 16)); + expect(seen.last.url.path, '/api/shops/1/draws/2026-10-16'); + expect(d.draw.label, 'Suvarna Keralam · SK-74'); + expect(d.orders.single.id, 11); + }); + }); + + group('changing things', () { + test('sends each edit to the right place', () async { + final (api, seen, _) = await _api( + (r) => r.method == 'POST' ? _json(_shop, 201) : http.Response('', 204), + ); + await api.setLineQty(11, '1234', 5); + await api.removeLine(11, '1234'); + await api.cancelOrder(11); + await api.setShopActive(1, false); + await api.renameShop(1, 'New name'); + await api.setUserShops(7, [1, 2]); + await api.deleteUser(7); + final shop = await api.createShop('Kozhikode', 'KZK'); + + final calls = [for (final r in seen) '${r.method} ${r.url.path}']; + expect(calls, [ + 'PUT /api/orders/11/lines/1234', + 'DELETE /api/orders/11/lines/1234', + 'DELETE /api/orders/11', + 'PATCH /api/shops/1', + 'PATCH /api/shops/1', + 'PUT /api/users/7/shops', + 'DELETE /api/users/7', + 'POST /api/shops', + ]); + expect(jsonDecode(seen[0].body), {'qty': 5}); + expect(jsonDecode(seen[3].body), {'active': false}); + expect(jsonDecode(seen[5].body), { + 'shopIds': [1, 2], + }); + expect(shop.code, 'KZK'); + }); + + test('sends only the user fields that changed', () async { + final (api, seen, _) = await _api((_) => http.Response('', 204)); + await api.updateUser(7, username: 'new.name'); + await api.updateUser(7, clearPhone: true); + await api.updateUser(7, active: false, password: 'longenough'); + await api.updateUser(7, sendResults: true); + expect(jsonDecode(seen[0].body), {'username': 'new.name'}); + expect(jsonDecode(seen[1].body), {'phone': null}); + expect(jsonDecode(seen[2].body), { + 'password': 'longenough', + 'active': false, + }); + expect(jsonDecode(seen[3].body), {'sendResults': true}); + }); + + test( + 'reads the results setting, off when the server leaves it out', + () async { + final (api, _, _) = await _api( + (r) => r.url.path == '/api/users/7' + ? _json({..._user, 'sendResults': true}) + : _json(_user), + ); + expect((await api.user(7)).sendResults, isTrue); + expect((await api.user(8)).sendResults, isFalse); + }, + ); + + test('creates a user with a password and shops', () async { + final (api, seen, _) = await _api((_) => _json(_user, 201)); + final u = await api.createUser( + username: 'kzk.staff', + password: 'longenough', + shopIds: [1, 2], + ); + expect(jsonDecode(seen.single.body), { + 'username': 'kzk.staff', + 'phone': null, + 'password': 'longenough', + 'shopIds': [1, 2], + }); + expect(u.id, 7); + }); + }); +} diff --git a/test/support/fake_api.dart b/test/support/fake_api.dart new file mode 100644 index 0000000..d20831a --- /dev/null +++ b/test/support/fake_api.dart @@ -0,0 +1,770 @@ +import 'package:srs_app/core/api.dart'; +import 'package:srs_app/core/format.dart'; +import 'package:srs_app/core/models.dart'; + +class _U { + _U( + this.id, + this.username, + this.phone, + this.password, { + this.isAdmin = false, + this.active = true, + this.sendResults = false, + List? shops, + }) : shops = shops ?? []; + final int id; + String username; + String? phone; + String password; + final bool isAdmin; + bool active; + bool sendResults; + List shops; +} + +class _O { + _O( + this.id, + this.shopId, + this.drawDate, + this.time, + this.isPhoto, + this.lines, { + this.edited = false, + }); + final int id; + final int shopId; + final DateTime drawDate; + final DateTime time; + final bool isPhoto; + final Map lines; + bool edited; + bool cancelled = false; +} + +/// Test double: an in-memory stand-in for srs-api with a fixed fixture +/// (SK-74, Fri 16 Oct 2026). Used only by the tests; never shipped in the app. +/// Results are computed on read (orders x prizes), as srs-api does. +class FakeApi implements Api { + FakeApi() { + _seed(); + } + + @override + final DateTime now = DateTime(2026, 10, 16, 17, 0); + + AppUser? _me; + final List _shops = []; + final List<_U> _users = []; + final List<_O> _orders = []; + int _nextShop = 1, _nextUser = 1, _nextOrder = 1; + + static const _prizes = >{ + '2026-10-16': { + '2851': (5, 2000), + '1360': (6, 1000), + '0987': (7, 500), + '6184': (7, 500), + '0376': (8, 200), + '1903': (8, 200), + '3821': (8, 200), + '2564': (9, 100), + '0119': (9, 100), + }, + }; + + void _seed() { + Shop shop(String code, String name) { + final s = Shop(id: _nextShop++, code: code, name: name); + _shops.add(s); + return s; + } + + final kzk = shop('KZK', 'Kazhakkoottam'); + final skm = shop('SKM', 'Sreekaryam'); + final ulr = shop('ULR', 'Ulloor'); + final ptm = shop('PTM', 'Pattom'); + + _U user( + String u, + String? phone, + String pw, { + bool admin = false, + bool active = true, + bool results = false, + List? shops, + }) { + final x = _U( + _nextUser++, + u, + phone, + pw, + isAdmin: admin, + active: active, + sendResults: results, + shops: shops, + ); + _users.add(x); + return x; + } + + user('owner', '919000012345', 'admin123', admin: true); + user('manager', '919000054321', 'admin123', admin: true); + user( + 'kzk.staff', + '919000011111', + 'staff123', + results: true, + shops: [kzk.id, skm.id], + ); + user('skm.staff', null, 'staff123', shops: [skm.id]); + user( + 'ptm.staff', + '919000022222', + 'staff123', + active: false, + shops: [ptm.id], + ); + + final d = DateTime(2026, 10, 16); + void order( + Shop s, + DateTime day, + DateTime t, + bool photo, + Map lines, { + bool edited = false, + }) { + _orders.add(_O(_nextOrder++, s.id, day, t, photo, lines, edited: edited)); + } + + // KZK: 180 tickets / 52 endings across two orders (64 + 116). + const kzkAll = <(String, int)>[ + ('0003', 5), + ('0018', 2), + ('0057', 2), + ('0166', 1), + ('0231', 6), + ('0376', 2), + ('0418', 5), + ('0745', 2), + ('0887', 10), + ('0888', 1), + ('0987', 4), + ('1122', 3), + ('1207', 4), + ('1360', 1), + ('1573', 3), + ('1892', 2), + ('2290', 2), + ('2417', 1), + ('2564', 3), + ('2689', 1), + ('3076', 5), + ('3345', 10), + ('3422', 2), + ('3472', 1), + ('3484', 4), + ('3584', 3), + ('3611', 3), + ('3704', 4), + ('4129', 1), + ('4410', 1), + ('4455', 6), + ('4568', 6), + ('4852', 2), + ('5043', 4), + ('5104', 1), + ('5105', 5), + ('5106', 3), + ('5127', 3), + ('5936', 10), + ('6358', 2), + ('6602', 6), + ('6861', 2), + ('7021', 1), + ('7216', 10), + ('7788', 2), + ('7790', 12), + ('8147', 3), + ('8301', 2), + ('8890', 1), + ('9064', 1), + ('9077', 3), + ('9155', 1), + ]; + final a = {}, b = {}; + var left = 64; + for (final (e, q) in kzkAll) { + if (left >= q) { + a[e] = q; + left -= q; + } else if (left > 0) { + a[e] = left; + b[e] = q - left; + left = 0; + } else { + b[e] = q; + } + } + order(kzk, d, DateTime(2026, 10, 16, 12, 41), true, a); + order(kzk, d, DateTime(2026, 10, 16, 11, 52), false, b, edited: true); + + order(skm, d, DateTime(2026, 10, 16, 12, 18), false, { + '0887': 1, + '0166': 1, + '3611': 1, + '4568': 2, + '6861': 1, + '0018': 1, + '0057': 2, + '2851': 1, + '6184': 1, + '0119': 1, + '9155': 20, + '5124': 5, + '0003': 3, + '7731': 2, + '4402': 4, + '1288': 3, + '3399': 2, + '8020': 3, + }); + order(skm, d, DateTime(2026, 10, 16, 10, 47), false, { + '1001': 10, + '1102': 12, + '2203': 8, + '3304': 14, + '4405': 6, + '5506': 10, + '6607': 9, + '7708': 7, + '8809': 10, + }); + order(ulr, d, DateTime(2026, 10, 16, 11, 30), true, { + '1360': 1, + '1903': 1, + '1010': 20, + '2020': 15, + '3030': 12, + '4040': 18, + '5050': 14, + '6060': 10, + '7070': 12, + '8080': 7, + }); + order(ptm, d, DateTime(2026, 10, 16, 10, 5), false, { + '3821': 1, + '1111': 15, + '2222': 12, + '3333': 10, + '4444': 8, + '5555': 10, + '6666': 9, + '7777': 5, + }); + + // Orders for tomorrow's draw: results not out yet. + final n = DateTime(2026, 10, 17); + order(kzk, n, DateTime(2026, 10, 16, 15, 20), false, { + '0042': 10, + '7311': 5, + '2208': 5, + }); + order(ulr, n, DateTime(2026, 10, 16, 16, 5), true, {'5521': 8, '3190': 2}); + } + + Future _t() => Future.delayed(const Duration(milliseconds: 160)); + + // ---- helpers ----------------------------------------------------------- + + bool _published(DateTime d) { + final day = dateOnly(d), today = dateOnly(now); + if (day.isBefore(today)) return true; + if (sameDay(day, today)) return now.hour >= 16; + return false; + } + + (int, int)? _prize(DateTime d, String ending) { + if (!_published(d)) return null; + return _prizes[isoDate(d)]?[ending]; + } + + Set _scope() { + final me = _me; + if (me == null) return {}; + final active = _shops.where((s) => s.active).map((s) => s.id).toSet(); + final u = _users.firstWhere((x) => x.id == me.id); + if (!u.active) return {}; + if (u.isAdmin) return active; + return u.shops.where(active.contains).toSet(); + } + + static const _schemes = { + 1: ('Bhagyathara', 'BT'), + 2: ('Sthree Sakthi', 'SS'), + 3: ('Dhanalekshmi', 'DL'), + 4: ('Karunya Plus', 'KN'), + 5: ('Suvarna Keralam', 'SK'), + 6: ('Karunya', 'KR'), + 7: ('Samrudhi', 'SM'), + }; + + List _hits((int, int)? p) => + p == null ? const [] : [PrizeHit(tier: p.$1, amount: p.$2)]; + + DrawInfo _info(DateTime d, {bool? hasWin}) { + final s = _schemes[d.weekday]!; + final weeks = d.difference(DateTime(2026, 10, 16)).inDays ~/ 7; + final published = _published(d); + return DrawInfo( + date: d, + scheme: published ? s.$1 : null, + code: published ? '${s.$2}-${74 + weeks}' : null, + published: published, + hasWin: hasWin ?? false, + ); + } + + List<_O> _ordersFor(DateTime d, {int? shopId}) { + final scope = _scope(); + return _orders + .where( + (o) => + !o.cancelled && + sameDay(o.drawDate, d) && + scope.contains(o.shopId) && + (shopId == null || o.shopId == shopId), + ) + .toList(); + } + + Map _sum(Iterable<_O> os) { + final m = {}; + for (final o in os) { + o.lines.forEach((e, q) => m[e] = (m[e] ?? 0) + q); + } + return m; + } + + Shop _shop(int id) => _shops.firstWhere((s) => s.id == id); + + Order _toOrder(_O o) { + final keys = o.lines.keys.toList()..sort(); + final lines = [ + for (final e in keys) + () { + final p = _prize(o.drawDate, e); + return EndingQty(ending: e, qty: o.lines[e]!, hits: _hits(p)); + }(), + ]; + final shop = _shop(o.shopId); + return Order( + id: o.id, + shop: shop, + drawDate: o.drawDate, + time: o.time, + isPhoto: o.isPhoto, + edited: o.edited, + lines: lines, + rawText: o.isPhoto + ? null + : '${shop.code}\n${keys.map((e) => '$e - ${o.lines[e]}').join('\n')}', + ); + } + + _U _admin() { + final me = _me; + if (me == null || !me.isAdmin) { + throw ApiException('You do not have permission to do this.'); + } + return _users.firstWhere((u) => u.id == me.id); + } + + AppUser _toUser(_U u) => AppUser( + id: u.id, + username: u.username, + phone: u.phone, + isAdmin: u.isAdmin, + active: u.active, + sendResults: u.sendResults, + shopIds: u.isAdmin + ? _shops.where((s) => s.active).map((s) => s.id).toList() + : List.of(u.shops), + ); + + _O _scopedOrder(int id) { + final scope = _scope(); + return _orders.firstWhere( + (o) => o.id == id && !o.cancelled && scope.contains(o.shopId), + orElse: () => throw ApiException('Order not found.'), + ); + } + + // ---- auth -------------------------------------------------------------- + + @override + Stream get sessionEnded => const Stream.empty(); + + @override + Future latestDrawDate() async => DateTime(2026, 10, 16); + + @override + Future login(String username, String password) async { + await _t(); + final u = _users + .where((x) => x.username.toLowerCase() == username.trim().toLowerCase()) + .firstOrNull; + if (u == null || u.password != password || !u.active) { + throw ApiException('Wrong username or password'); + } + _me = _toUser(u); + return _me!; + } + + /// Everything lives in memory, so a reload starts signed out. + @override + Future restore() async => _me; + + @override + void logout() => _me = null; + + // ---- results ----------------------------------------------------------- + + @override + Future> month(int year, int month) async { + await _t(); + final days = DateTime(year, month + 1, 0).day; + return [ + for (var i = 1; i <= days; i++) + () { + final d = DateTime(year, month, i); + var win = false; + if (_published(d)) { + win = _ordersFor( + d, + ).any((o) => o.lines.keys.any((e) => _prize(d, e) != null)); + } + return _info(d, hasWin: win); + }(), + ]; + } + + @override + Future results(DateTime date) async { + await _t(); + final d = dateOnly(date); + final scope = _scope(); + final rows = []; + for (final s in _shops.where((s) => s.active && scope.contains(s.id))) { + final os = _ordersFor(d, shopId: s.id); + if (os.isEmpty) continue; + var tickets = 0, wt = 0, prize = 0; + _sum(os).forEach((e, q) { + tickets += q; + final p = _prize(d, e); + if (p != null) { + wt += q; + prize += q * p.$2; + } + }); + rows.add( + ShopResult( + shop: s, + tickets: tickets, + orders: os.length, + winTickets: wt, + prize: prize, + ), + ); + } + rows.sort((x, y) { + final c = y.prize.compareTo(x.prize); + return c != 0 ? c : y.tickets.compareTo(x.tickets); + }); + final win = rows.any((r) => r.prize > 0); + return DrawResults( + draw: _info(d, hasWin: win), + shops: rows, + ); + } + + // ---- shops ------------------------------------------------------------- + + @override + Future> shops(DateTime date) async { + await _t(); + final d = dateOnly(date); + final scope = _scope(); + return [ + for (final s in _shops.where((s) => s.active && scope.contains(s.id))) + () { + final os = _ordersFor(d, shopId: s.id); + final sums = _sum(os); + return ShopDay( + shop: s, + tickets: sums.values.fold(0, (a, b) => a + b), + orders: os.length, + endings: sums.length, + ); + }(), + // Admins also see deactivated shops so they can switch them back on. + if (_me?.isAdmin == true) + for (final s in _shops.where((s) => !s.active)) + ShopDay(shop: s, tickets: 0, orders: 0, endings: 0), + ]; + } + + @override + Future shopDetail(int shopId, DateTime date) async { + await _t(); + final d = dateOnly(date); + if (!_scope().contains(shopId)) throw ApiException('Shop not found.'); + final os = _ordersFor(d, shopId: shopId); + final sums = _sum(os); + final keys = sums.keys.toList()..sort(); + final endings = [ + for (final e in keys) + () { + final p = _prize(d, e); + return EndingQty(ending: e, qty: sums[e]!, hits: _hits(p)); + }(), + ]; + final orders = os.map(_toOrder).toList() + ..sort((x, y) => y.time.compareTo(x.time)); + return ShopDetail( + shop: _shop(shopId), + draw: _info(d, hasWin: endings.any((e) => e.wins)), + endings: endings, + orders: orders, + ); + } + + @override + Future> activeShops() async { + final scope = _scope(); + return _shops.where((s) => s.active && scope.contains(s.id)).toList(); + } + + @override + Future createShop(String name, String code) async { + await _t(); + _admin(); + final n = name.trim(), c = code.trim().toUpperCase(); + if (n.isEmpty) throw ApiException('Shop name is required.'); + if (n.length > 100) { + throw ApiException('Shop name must be 100 characters or fewer.'); + } + if (c.isEmpty || c.length > 20 || c.contains(RegExp(r'\s'))) { + throw ApiException('Short code must be 1–20 characters with no spaces.'); + } + if (_shops.any((s) => s.code.toUpperCase() == c)) { + throw ApiException('This short code is already in use.'); + } + if (_shops.any( + (s) => s.active && s.name.toLowerCase() == n.toLowerCase(), + )) { + throw ApiException('A shop with this name already exists.'); + } + final s = Shop(id: _nextShop++, code: c, name: n); + _shops.add(s); + return s; + } + + @override + Future renameShop(int shopId, String name) async { + await _t(); + _admin(); + final n = name.trim(); + if (n.isEmpty) throw ApiException('Shop name is required.'); + if (n.length > 100) { + throw ApiException('Shop name must be 100 characters or fewer.'); + } + if (_shops.any( + (s) => + s.id != shopId && s.active && s.name.toLowerCase() == n.toLowerCase(), + )) { + throw ApiException('A shop with this name already exists.'); + } + final i = _shops.indexWhere((s) => s.id == shopId); + _shops[i] = _shops[i].copyWith(name: n); + } + + @override + Future setShopActive(int shopId, bool active) async { + await _t(); + _admin(); + final i = _shops.indexWhere((s) => s.id == shopId); + if (active && + _shops.any( + (s) => + s.id != shopId && + s.active && + s.name.toLowerCase() == _shops[i].name.toLowerCase(), + )) { + throw ApiException('An active shop with this name already exists.'); + } + _shops[i] = _shops[i].copyWith(active: active); + } + + // ---- orders ------------------------------------------------------------ + + @override + Future> orders(DateTime date) async { + await _t(); + final list = _ordersFor(dateOnly(date)).map(_toOrder).toList() + ..sort((x, y) => y.time.compareTo(x.time)); + return list; + } + + @override + Future order(int orderId) async { + await _t(); + return _toOrder(_scopedOrder(orderId)); + } + + @override + Future setLineQty(int orderId, String ending, int qty) async { + await _t(); + final o = _scopedOrder(orderId); + if (!o.lines.containsKey(ending)) throw ApiException('Ending not found.'); + if (qty < 1 || qty > 9999) throw ApiException('Quantity must be 1–9999.'); + o.lines[ending] = qty; + o.edited = true; + } + + @override + Future removeLine(int orderId, String ending) async { + await _t(); + final o = _scopedOrder(orderId); + o.lines.remove(ending); + o.edited = true; + if (o.lines.isEmpty) o.cancelled = true; + } + + @override + Future cancelOrder(int orderId) async { + await _t(); + _scopedOrder(orderId).cancelled = true; + } + + // ---- users ------------------------------------------------------------- + + @override + Future> users() async { + await _t(); + _admin(); + // Like srs-api: the app manages users only, never admins. + return _users.where((u) => !u.isAdmin).map(_toUser).toList(); + } + + @override + Future user(int id) async { + await _t(); + _admin(); + final u = _users.firstWhere((u) => u.id == id); + if (u.isAdmin) { + throw ApiException('Administrator accounts cannot be changed here.'); + } + return _toUser(u); + } + + static final _nameRe = RegExp(r'^[A-Za-z0-9._-]{3,50}$'); + + String _checkUsername(String raw, {int? self}) { + final u = raw.trim(); + if (!_nameRe.hasMatch(u)) { + throw ApiException( + 'Username must be 3–50 characters: letters, numbers, dots, dashes or underscores.', + ); + } + if (_users.any( + (x) => x.id != self && x.username.toLowerCase() == u.toLowerCase(), + )) { + throw ApiException('This username is already taken.'); + } + return u; + } + + String? _checkPhone(String? raw) { + if (raw == null) return null; + var d = raw.replaceAll(RegExp(r'\D'), ''); + if (d.isEmpty) return null; + if (d.length == 10) d = '91$d'; + if (d.length != 12 || !d.startsWith('91')) { + throw ApiException('Enter a valid 10-digit mobile number.'); + } + return d; + } + + void _checkPassword(String p) { + if (p.length < 8) { + throw ApiException('Password must be at least 8 characters.'); + } + } + + @override + Future createUser({ + required String username, + String? phone, + required String password, + required List shopIds, + }) async { + await _t(); + _admin(); + final name = _checkUsername(username); + final ph = _checkPhone(phone); + _checkPassword(password); + final u = _U(_nextUser++, name, ph, password, shops: List.of(shopIds)); + _users.add(u); + return _toUser(u); + } + + _U _editable(int id) { + final u = _users.firstWhere((x) => x.id == id); + if (u.isAdmin) { + throw ApiException('Administrator accounts cannot be changed here.'); + } + return u; + } + + @override + Future updateUser( + int id, { + String? username, + String? phone, + bool clearPhone = false, + String? password, + bool? active, + bool? sendResults, + }) async { + await _t(); + _admin(); + final u = _editable(id); + if (username != null) u.username = _checkUsername(username, self: id); + if (clearPhone) u.phone = null; + if (phone != null) u.phone = _checkPhone(phone); + if (password != null) { + _checkPassword(password); + u.password = password; + } + if (active != null) u.active = active; + if (sendResults != null) u.sendResults = sendResults; + } + + @override + Future deleteUser(int id) async { + await _t(); + _admin(); + _editable(id); + _users.removeWhere((u) => u.id == id); + } + + @override + Future setUserShops(int id, List shopIds) async { + await _t(); + _admin(); + _editable(id).shops = List.of(shopIds); + } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..2027ed4 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..9e1a09f Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..1a15ce4 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..d01f4a2 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..501ef34 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1ca5eb4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + SRS + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..aa8b297 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "SRS", + "short_name": "SRS", + "start_url": ".", + "display": "standalone", + "background_color": "#F4F2EC", + "theme_color": "#1B2A49", + "description": "Your sequences, at a glance", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file