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.
This commit is contained in:
commit
45fcdcebfb
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
API_BASE_URL=http://127.0.0.1:8080
|
||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@ -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/
|
||||
18
.metadata
Normal file
18
.metadata
Normal file
@ -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'
|
||||
13
.vscode/launch.json
vendored
Normal file
13
.vscode/launch.json
vendored
Normal file
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
analysis_options.yaml
Normal file
24
analysis_options.yaml
Normal file
@ -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
|
||||
BIN
assets/fonts/Inter-Bold.otf
Normal file
BIN
assets/fonts/Inter-Bold.otf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Medium.otf
Normal file
BIN
assets/fonts/Inter-Medium.otf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Regular.otf
Normal file
BIN
assets/fonts/Inter-Regular.otf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-SemiBold.otf
Normal file
BIN
assets/fonts/Inter-SemiBold.otf
Normal file
Binary file not shown.
1
assets/icons/whatsapp.svg
Normal file
1
assets/icons/whatsapp.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WhatsApp</title><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@ -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:
|
||||
107
lib/app.dart
Normal file
107
lib/app.dart
Normal file
@ -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<GoRouter>((ref) {
|
||||
final refresh = ValueNotifier<int>(0);
|
||||
ref.listen(sessionProvider, (_, _) => refresh.value++);
|
||||
ref.onDispose(refresh.dispose);
|
||||
|
||||
Page<void> tab(GoRouterState s, Widget w) =>
|
||||
NoTransitionPage<void>(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/core/api.dart
Normal file
53
lib/core/api.dart
Normal file
@ -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<void> get sessionEnded;
|
||||
Future<DateTime> latestDrawDate();
|
||||
Future<AppUser> login(String username, String password);
|
||||
Future<AppUser?> restore();
|
||||
void logout();
|
||||
|
||||
Future<List<DrawInfo>> month(int year, int month);
|
||||
Future<DrawResults> results(DateTime date);
|
||||
|
||||
Future<List<ShopDay>> shops(DateTime date);
|
||||
Future<ShopDetail> shopDetail(int shopId, DateTime date);
|
||||
Future<List<Shop>> activeShops();
|
||||
Future<Shop> createShop(String name, String code);
|
||||
Future<void> renameShop(int shopId, String name);
|
||||
Future<void> setShopActive(int shopId, bool active);
|
||||
|
||||
Future<List<Order>> orders(DateTime date);
|
||||
Future<Order> order(int orderId);
|
||||
Future<void> setLineQty(int orderId, String ending, int qty);
|
||||
Future<void> removeLine(int orderId, String ending);
|
||||
Future<void> cancelOrder(int orderId);
|
||||
|
||||
Future<List<AppUser>> users();
|
||||
Future<AppUser> user(int id);
|
||||
Future<AppUser> createUser({
|
||||
required String username,
|
||||
String? phone,
|
||||
required String password,
|
||||
required List<int> shopIds,
|
||||
});
|
||||
Future<void> updateUser(
|
||||
int id, {
|
||||
String? username,
|
||||
String? phone,
|
||||
bool clearPhone = false,
|
||||
String? password,
|
||||
bool? active,
|
||||
bool? sendResults,
|
||||
});
|
||||
Future<void> setUserShops(int id, List<int> shopIds);
|
||||
Future<void> deleteUser(int id);
|
||||
}
|
||||
11
lib/core/config.dart
Normal file
11
lib/core/config.dart
Normal file
@ -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).';
|
||||
}
|
||||
117
lib/core/format.dart
Normal file
117
lib/core/format.dart
Normal file
@ -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 = <String>[];
|
||||
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<int> 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)),
|
||||
);
|
||||
361
lib/core/http_api.dart
Normal file
361
lib/core/http_api.dart
Normal file
@ -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<void>.broadcast();
|
||||
|
||||
@override
|
||||
DateTime get now => istWallClock(DateTime.now());
|
||||
|
||||
@override
|
||||
Stream<void> get sessionEnded => _ended.stream;
|
||||
|
||||
// ---- transport ---------------------------------------------------------
|
||||
|
||||
Uri _uri(String path, [Map<String, String>? query]) {
|
||||
final origin = _base.isEmpty ? Uri.base.origin : _base;
|
||||
return Uri.parse('$origin/api$path').replace(queryParameters: query);
|
||||
}
|
||||
|
||||
/// Sends a request and returns the decoded JSON body (null for 204).
|
||||
/// Any failure becomes an [ApiException] whose message can be shown as is.
|
||||
Future<Object?> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
Object? body,
|
||||
bool signedIn = true,
|
||||
}) async {
|
||||
final request = http.Request(method, _uri(path, query));
|
||||
final token = _prefs.getString(_tokenKey);
|
||||
if (signedIn && token != null) {
|
||||
request.headers['authorization'] = 'Bearer $token';
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers['content-type'] = 'application/json';
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http.Response.fromStream(
|
||||
await _client.send(request).timeout(timeout),
|
||||
);
|
||||
} on Exception {
|
||||
throw ApiException(_unreachable);
|
||||
}
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
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<String, dynamic>)['message'];
|
||||
if (m is String && m.isNotEmpty) return m;
|
||||
} on Object {
|
||||
// ignore: avoid_catching_errors
|
||||
}
|
||||
return r.statusCode >= 500
|
||||
? 'Something went wrong on the server. Try again shortly.'
|
||||
: 'The request could not be completed.';
|
||||
}
|
||||
|
||||
void _endSession() {
|
||||
_prefs.remove(_tokenKey);
|
||||
_ended.add(null);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _object(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
Object? body,
|
||||
bool signedIn = true,
|
||||
}) async =>
|
||||
await _send(method, path, query: query, body: body, signedIn: signedIn)
|
||||
as Map<String, dynamic>;
|
||||
|
||||
Future<List<Map<String, dynamic>>> _list(
|
||||
String path,
|
||||
String key, {
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
final json = await _object('GET', path, query: query);
|
||||
return (json[key] as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
// ---- mapping -----------------------------------------------------------
|
||||
|
||||
AppUser _user(Map<String, dynamic> j) => AppUser(
|
||||
id: j['id'] as int,
|
||||
username: j['username'] as String,
|
||||
phone: j['phone'] as String?,
|
||||
isAdmin: j['isAdmin'] as bool,
|
||||
active: j['active'] as bool,
|
||||
shopIds: (j['shopIds'] as List).cast<int>(),
|
||||
sendResults: j['sendResults'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Shop _shop(Map<String, dynamic> j) => Shop(
|
||||
id: j['id'] as int,
|
||||
code: j['code'] as String,
|
||||
name: j['name'] as String,
|
||||
active: j['active'] as bool,
|
||||
);
|
||||
|
||||
DrawInfo _draw(Map<String, dynamic> j) => DrawInfo(
|
||||
date: parseDate(j['date'] as String),
|
||||
scheme: j['scheme'] as String?,
|
||||
code: j['code'] as String?,
|
||||
published: j['published'] as bool,
|
||||
hasWin: j['hasWin'] as bool,
|
||||
);
|
||||
|
||||
EndingQty _line(Map<String, dynamic> j) => EndingQty(
|
||||
ending: j['ending'] as String,
|
||||
qty: j['qty'] as int,
|
||||
hits: [
|
||||
for (final h in (j['hits'] as List).cast<Map<String, dynamic>>())
|
||||
PrizeHit(tier: h['tier'] as int, amount: h['amount'] as int),
|
||||
],
|
||||
);
|
||||
|
||||
Order _order(Map<String, dynamic> j) => Order(
|
||||
id: j['id'] as int,
|
||||
shop: _shop(j['shop'] as Map<String, dynamic>),
|
||||
drawDate: parseDate(j['drawDate'] as String),
|
||||
time: istWallClock(DateTime.parse(j['createdAt'] as String)),
|
||||
isPhoto: j['isPhoto'] as bool,
|
||||
edited: j['edited'] as bool,
|
||||
rawText: j['rawText'] as String?,
|
||||
lines: [
|
||||
for (final l in (j['lines'] as List).cast<Map<String, dynamic>>())
|
||||
_line(l),
|
||||
],
|
||||
);
|
||||
|
||||
String _day(DateTime d) => isoDate(d);
|
||||
|
||||
// ---- auth --------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<DateTime> latestDrawDate() async =>
|
||||
parseDate((await _object('GET', '/draws/latest'))['date'] as String);
|
||||
|
||||
@override
|
||||
Future<AppUser> login(String username, String password) async {
|
||||
final json = await _object(
|
||||
'POST',
|
||||
'/auth/login',
|
||||
body: {'username': username.trim(), 'password': password},
|
||||
signedIn: false,
|
||||
);
|
||||
await _prefs.setString(_tokenKey, json['token'] as String);
|
||||
return _user(json['user'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppUser?> restore() async {
|
||||
if (_prefs.getString(_tokenKey) == null) return null;
|
||||
try {
|
||||
return _user(await _object('GET', '/me'));
|
||||
} on ApiException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void logout() => _prefs.remove(_tokenKey);
|
||||
|
||||
// ---- results -----------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<DrawInfo>> month(int year, int month) async {
|
||||
final text = '$year-${month.toString().padLeft(2, '0')}';
|
||||
return [
|
||||
for (final d in await _list('/draws', 'days', query: {'month': text}))
|
||||
_draw(d),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DrawResults> results(DateTime date) async {
|
||||
final json = await _object('GET', '/draws/${_day(date)}/results');
|
||||
return DrawResults(
|
||||
draw: _draw(json['draw'] as Map<String, dynamic>),
|
||||
shops: [
|
||||
for (final s in (json['shops'] as List).cast<Map<String, dynamic>>())
|
||||
ShopResult(
|
||||
shop: _shop(s['shop'] as Map<String, dynamic>),
|
||||
tickets: s['tickets'] as int,
|
||||
orders: s['orders'] as int,
|
||||
winTickets: s['winTickets'] as int,
|
||||
prize: s['prize'] as int,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---- shops -------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<ShopDay>> shops(DateTime date) async => [
|
||||
for (final s in await _list('/shops', 'shops', query: {'date': _day(date)}))
|
||||
ShopDay(
|
||||
shop: _shop(s['shop'] as Map<String, dynamic>),
|
||||
tickets: s['tickets'] as int,
|
||||
orders: s['orders'] as int,
|
||||
endings: s['endings'] as int,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<ShopDetail> shopDetail(int shopId, DateTime date) async {
|
||||
final json = await _object('GET', '/shops/$shopId/draws/${_day(date)}');
|
||||
return ShopDetail(
|
||||
shop: _shop(json['shop'] as Map<String, dynamic>),
|
||||
draw: _draw(json['draw'] as Map<String, dynamic>),
|
||||
endings: [
|
||||
for (final e in (json['endings'] as List).cast<Map<String, dynamic>>())
|
||||
_line(e),
|
||||
],
|
||||
orders: [
|
||||
for (final o in (json['orders'] as List).cast<Map<String, dynamic>>())
|
||||
_order(o),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shop>> activeShops() async => [
|
||||
for (final s in await _list('/shops/options', 'shops')) _shop(s),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<Shop> createShop(String name, String code) async => _shop(
|
||||
await _object('POST', '/shops', body: {'name': name, 'code': code}),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> renameShop(int shopId, String name) =>
|
||||
_send('PATCH', '/shops/$shopId', body: {'name': name});
|
||||
|
||||
@override
|
||||
Future<void> setShopActive(int shopId, bool active) =>
|
||||
_send('PATCH', '/shops/$shopId', body: {'active': active});
|
||||
|
||||
// ---- orders ------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<Order>> orders(DateTime date) async => [
|
||||
for (final o in await _list(
|
||||
'/orders',
|
||||
'orders',
|
||||
query: {'date': _day(date)},
|
||||
))
|
||||
_order(o),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<Order> order(int orderId) async =>
|
||||
_order(await _object('GET', '/orders/$orderId'));
|
||||
|
||||
@override
|
||||
Future<void> setLineQty(int orderId, String ending, int qty) =>
|
||||
_send('PUT', '/orders/$orderId/lines/$ending', body: {'qty': qty});
|
||||
|
||||
@override
|
||||
Future<void> removeLine(int orderId, String ending) =>
|
||||
_send('DELETE', '/orders/$orderId/lines/$ending');
|
||||
|
||||
@override
|
||||
Future<void> cancelOrder(int orderId) => _send('DELETE', '/orders/$orderId');
|
||||
|
||||
// ---- users -------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<AppUser>> users() async => [
|
||||
for (final u in await _list('/users', 'users')) _user(u),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<AppUser> user(int id) async =>
|
||||
_user(await _object('GET', '/users/$id'));
|
||||
|
||||
@override
|
||||
Future<AppUser> createUser({
|
||||
required String username,
|
||||
String? phone,
|
||||
required String password,
|
||||
required List<int> shopIds,
|
||||
}) async => _user(
|
||||
await _object(
|
||||
'POST',
|
||||
'/users',
|
||||
body: {
|
||||
'username': username,
|
||||
'phone': phone,
|
||||
'password': password,
|
||||
'shopIds': shopIds,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateUser(
|
||||
int id, {
|
||||
String? username,
|
||||
String? phone,
|
||||
bool clearPhone = false,
|
||||
String? password,
|
||||
bool? active,
|
||||
bool? sendResults,
|
||||
}) => _send(
|
||||
'PATCH',
|
||||
'/users/$id',
|
||||
body: {
|
||||
'username': ?username,
|
||||
if (clearPhone) 'phone': null else 'phone': ?phone,
|
||||
'password': ?password,
|
||||
'active': ?active,
|
||||
'sendResults': ?sendResults,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setUserShops(int id, List<int> shopIds) =>
|
||||
_send('PUT', '/users/$id/shops', body: {'shopIds': shopIds});
|
||||
|
||||
@override
|
||||
Future<void> deleteUser(int id) => _send('DELETE', '/users/$id');
|
||||
}
|
||||
173
lib/core/models.dart
Normal file
173
lib/core/models.dart
Normal file
@ -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<int> 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<PrizeHit> 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<EndingQty> 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<ShopResult> 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<EndingQty> endings;
|
||||
final List<Order> orders;
|
||||
|
||||
int get tickets => endings.fold(0, (a, e) => a + e.qty);
|
||||
List<EndingQty> 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);
|
||||
}
|
||||
122
lib/core/providers.dart
Normal file
122
lib/core/providers.dart
Normal file
@ -0,0 +1,122 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'format.dart';
|
||||
import 'models.dart';
|
||||
|
||||
final apiProvider = Provider<Api>((ref) => throw UnimplementedError());
|
||||
|
||||
final startDateProvider = Provider<DateTime>(
|
||||
(ref) => throw UnimplementedError(),
|
||||
);
|
||||
|
||||
class DataVersion extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
void bump() => state++;
|
||||
}
|
||||
|
||||
final dataVersionProvider = NotifierProvider<DataVersion, int>(DataVersion.new);
|
||||
|
||||
class SessionNotifier extends Notifier<AppUser?> {
|
||||
SessionNotifier(this.initial);
|
||||
final AppUser? initial;
|
||||
|
||||
@override
|
||||
AppUser? build() {
|
||||
final sub = ref.read(apiProvider).sessionEnded.listen((_) => _end());
|
||||
ref.onDispose(sub.cancel);
|
||||
return initial;
|
||||
}
|
||||
|
||||
Future<void> 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<void> logout() async {
|
||||
ref.read(apiProvider).logout();
|
||||
_end();
|
||||
}
|
||||
|
||||
void _end() {
|
||||
state = null;
|
||||
ref.invalidate(selectedDateProvider);
|
||||
}
|
||||
}
|
||||
|
||||
final sessionProvider = NotifierProvider<SessionNotifier, AppUser?>(
|
||||
() => SessionNotifier(null),
|
||||
);
|
||||
|
||||
class SelectedDate extends Notifier<DateTime> {
|
||||
@override
|
||||
DateTime build() => ref.read(startDateProvider);
|
||||
void set(DateTime d) => state = dateOnly(d);
|
||||
}
|
||||
|
||||
final selectedDateProvider = NotifierProvider<SelectedDate, DateTime>(
|
||||
SelectedDate.new,
|
||||
);
|
||||
|
||||
final resultsProvider = FutureProvider.family<DrawResults, DateTime>((ref, d) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).results(d);
|
||||
});
|
||||
|
||||
final shopsProvider = FutureProvider.family<List<ShopDay>, DateTime>((ref, d) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).shops(d);
|
||||
});
|
||||
|
||||
final shopDetailProvider = FutureProvider.family<ShopDetail, (int, DateTime)>((
|
||||
ref,
|
||||
k,
|
||||
) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).shopDetail(k.$1, k.$2);
|
||||
});
|
||||
|
||||
final ordersProvider = FutureProvider.family<List<Order>, DateTime>((ref, d) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).orders(d);
|
||||
});
|
||||
|
||||
final orderProvider = FutureProvider.family<Order, int>((ref, id) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).order(id);
|
||||
});
|
||||
|
||||
final monthProvider = FutureProvider.family<List<DrawInfo>, (int, int)>((
|
||||
ref,
|
||||
k,
|
||||
) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).month(k.$1, k.$2);
|
||||
});
|
||||
|
||||
final activeShopsProvider = FutureProvider<List<Shop>>((ref) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).activeShops();
|
||||
});
|
||||
|
||||
final usersProvider = FutureProvider<List<AppUser>>((ref) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).users();
|
||||
});
|
||||
|
||||
final userProvider = FutureProvider.family<AppUser, int>((ref, id) {
|
||||
ref.watch(dataVersionProvider);
|
||||
return ref.read(apiProvider).user(id);
|
||||
});
|
||||
|
||||
Future<T> mutate<T>(WidgetRef ref, Future<T> Function() fn) async {
|
||||
final r = await fn();
|
||||
ref.read(dataVersionProvider.notifier).bump();
|
||||
return r;
|
||||
}
|
||||
159
lib/core/theme.dart
Normal file
159
lib/core/theme.dart
Normal file
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
246
lib/features/auth/login_screen.dart
Normal file
246
lib/features/auth/login_screen.dart
Normal file
@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _user = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_user.dispose();
|
||||
_pass.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
);
|
||||
}
|
||||
81
lib/features/orders/order_card.dart
Normal file
81
lib/features/orders/order_card.dart
Normal file
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
341
lib/features/orders/order_detail_screen.dart
Normal file
341
lib/features/orders/order_detail_screen.dart
Normal file
@ -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<OrderDetailScreen> createState() => _OrderDetailScreenState();
|
||||
}
|
||||
|
||||
class _OrderDetailScreenState extends ConsumerState<OrderDetailScreen> {
|
||||
bool _full = false;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _run(Future<void> 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<void> _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<void> _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<void> _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),
|
||||
),
|
||||
);
|
||||
}
|
||||
135
lib/features/orders/orders_screen.dart
Normal file
135
lib/features/orders/orders_screen.dart
Normal file
@ -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<OrdersScreen> createState() => _OrdersScreenState();
|
||||
}
|
||||
|
||||
class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
||||
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 = <String>{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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
394
lib/features/results/calendar_picker.dart
Normal file
394
lib/features/results/calendar_picker.dart
Normal file
@ -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<void> showCalendarPicker(BuildContext anchor) {
|
||||
if (!isWide(anchor)) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<void>(
|
||||
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 = <int, DrawInfo>{
|
||||
for (final d in days.asData?.value ?? const <DrawInfo>[]) 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<int, DrawInfo> 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
338
lib/features/results/results_screen.dart
Normal file
338
lib/features/results/results_screen.dart
Normal file
@ -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<Widget> _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<Widget> _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<Widget> 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
294
lib/features/shops/shop_detail_screen.dart
Normal file
294
lib/features/shops/shop_detail_screen.dart
Normal file
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
232
lib/features/shops/shop_sheet.dart
Normal file
232
lib/features/shops/shop_sheet.dart
Normal file
@ -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<void> showShopSheet(
|
||||
BuildContext context, {
|
||||
Shop? shop,
|
||||
VoidCallback? onDeactivated,
|
||||
}) {
|
||||
return showSheet<void>(
|
||||
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<void> _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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/features/shops/shops_screen.dart
Normal file
163
lib/features/shops/shops_screen.dart
Normal file
@ -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<ShopsScreen> createState() => _ShopsScreenState();
|
||||
}
|
||||
|
||||
class _ShopsScreenState extends ConsumerState<ShopsScreen> {
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
173
lib/features/shops/tickets_screen.dart
Normal file
173
lib/features/shops/tickets_screen.dart
Normal file
@ -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<TicketsScreen> createState() => _TicketsScreenState();
|
||||
}
|
||||
|
||||
class _TicketsScreenState extends ConsumerState<TicketsScreen> {
|
||||
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}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/features/users/shop_picker.dart
Normal file
92
lib/features/users/shop_picker.dart
Normal file
@ -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<Shop> shops;
|
||||
final Set<int> selected;
|
||||
final ValueChanged<int> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
167
lib/features/users/user_bits.dart
Normal file
167
lib/features/users/user_bits.dart
Normal file
@ -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<Shop> 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
715
lib/features/users/user_detail_screen.dart
Normal file
715
lib/features/users/user_detail_screen.dart
Normal file
@ -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 <Shop>[];
|
||||
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<Shop> shops;
|
||||
|
||||
Future<void> _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<void> _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<void>(context, (_) => _EditField(user: u, field: f));
|
||||
}
|
||||
|
||||
void _shopsSheet(BuildContext context, WidgetRef ref) {
|
||||
showSheet<void>(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<bool>? 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<void> _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<void> _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<Shop> shops;
|
||||
|
||||
@override
|
||||
ConsumerState<_ShopsEditor> createState() => _ShopsEditorState();
|
||||
}
|
||||
|
||||
class _ShopsEditorState extends ConsumerState<_ShopsEditor> {
|
||||
late final Set<int> _sel = widget.user.shopIds.toSet();
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
139
lib/features/users/user_form_screen.dart
Normal file
139
lib/features/users/user_form_screen.dart
Normal file
@ -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<UserFormScreen> createState() => _UserFormScreenState();
|
||||
}
|
||||
|
||||
class _UserFormScreenState extends ConsumerState<UserFormScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _shops = <int>{};
|
||||
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<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
404
lib/features/users/users_screen.dart
Normal file
404
lib/features/users/users_screen.dart
Normal file
@ -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<UsersScreen> createState() => _UsersScreenState();
|
||||
}
|
||||
|
||||
class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
_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 <Shop>[];
|
||||
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<String> 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<AppUser> users;
|
||||
final Map<int, Shop> 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<int, Shop> 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/main.dart
Normal file
41
lib/main.dart
Normal file
@ -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<void> 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<DateTime> _latestOrToday(Api api) async {
|
||||
try {
|
||||
return await api.latestDrawDate();
|
||||
} on ApiException {
|
||||
return dateOnly(api.now);
|
||||
}
|
||||
}
|
||||
85
lib/widgets/account_sheet.dart
Normal file
85
lib/widgets/account_sheet.dart
Normal file
@ -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<void>(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),
|
||||
),
|
||||
);
|
||||
}
|
||||
393
lib/widgets/main_shell.dart
Normal file
393
lib/widgets/main_shell.dart
Normal file
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
1082
lib/widgets/ui.dart
Normal file
1082
lib/widgets/ui.dart
Normal file
File diff suppressed because it is too large
Load Diff
559
pubspec.lock
Normal file
559
pubspec.lock
Normal file
@ -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"
|
||||
104
pubspec.yaml
Normal file
104
pubspec.yaml
Normal file
@ -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
|
||||
150
test/app_test.dart
Normal file
150
test/app_test.dart
Normal file
@ -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<void> 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<void> 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<Switch>(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<Switch>(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<Switch>(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<Switch>(find.byType(Switch).last).onChanged, isNull);
|
||||
});
|
||||
}
|
||||
210
test/core_test.dart
Normal file
210
test/core_test.dart
Normal file
@ -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<ApiException>()));
|
||||
});
|
||||
|
||||
test('user cannot manage shops or users', () async {
|
||||
final api = FakeApi();
|
||||
await api.login('kzk.staff', 'staff123');
|
||||
expect(() => api.createShop('X', 'XXX'), throwsA(isA<ApiException>()));
|
||||
expect(() => api.users(), throwsA(isA<ApiException>()));
|
||||
});
|
||||
|
||||
test('wrong password and inactive users cannot sign in', () async {
|
||||
final api = FakeApi();
|
||||
expect(() => api.login('owner', 'nope'), throwsA(isA<ApiException>()));
|
||||
expect(
|
||||
() => api.login('ptm.staff', 'staff123'),
|
||||
throwsA(isA<ApiException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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<ApiException>()));
|
||||
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<ApiException>()),
|
||||
);
|
||||
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<ApiException>()),
|
||||
);
|
||||
expect(
|
||||
() =>
|
||||
api.createUser(username: 'newuser', password: 'short', shopIds: []),
|
||||
throwsA(isA<ApiException>()),
|
||||
);
|
||||
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<ApiException>()));
|
||||
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<ApiException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
343
test/http_api_test.dart
Normal file
343
test/http_api_test.dart
Normal file
@ -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': <Object>[]},
|
||||
],
|
||||
};
|
||||
|
||||
http.Response _json(Object body, [int status = 200]) => http.Response(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
|
||||
Future<(HttpApi, List<http.Request>, SharedPreferences)> _api(
|
||||
http.Response Function(http.Request) reply, {
|
||||
Map<String, Object> prefs = const {},
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final seen = <http.Request>[];
|
||||
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<ApiException>().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<void>.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<ApiException>()),
|
||||
);
|
||||
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<ApiException>().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('<html>', 502));
|
||||
await expectLater(
|
||||
api.activeShops(),
|
||||
throwsA(
|
||||
isA<ApiException>().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': <Object>[]})
|
||||
: _json({
|
||||
'shop': _shop,
|
||||
'draw': {
|
||||
'date': '2026-10-16',
|
||||
'scheme': 'Suvarna Keralam',
|
||||
'code': 'SK-74',
|
||||
'published': true,
|
||||
'hasWin': true,
|
||||
},
|
||||
'endings': <Object>[],
|
||||
'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);
|
||||
});
|
||||
});
|
||||
}
|
||||
770
test/support/fake_api.dart
Normal file
770
test/support/fake_api.dart
Normal file
@ -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<int>? shops,
|
||||
}) : shops = shops ?? [];
|
||||
final int id;
|
||||
String username;
|
||||
String? phone;
|
||||
String password;
|
||||
final bool isAdmin;
|
||||
bool active;
|
||||
bool sendResults;
|
||||
List<int> 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<String, int> 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<Shop> _shops = [];
|
||||
final List<_U> _users = [];
|
||||
final List<_O> _orders = [];
|
||||
int _nextShop = 1, _nextUser = 1, _nextOrder = 1;
|
||||
|
||||
static const _prizes = <String, Map<String, (int, int)>>{
|
||||
'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<int>? 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<String, int> 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 = <String, int>{}, b = <String, int>{};
|
||||
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<void> _t() => Future<void>.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<int> _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<PrizeHit> _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<String, int> _sum(Iterable<_O> os) {
|
||||
final m = <String, int>{};
|
||||
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<void> get sessionEnded => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<DateTime> latestDrawDate() async => DateTime(2026, 10, 16);
|
||||
|
||||
@override
|
||||
Future<AppUser> 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<AppUser?> restore() async => _me;
|
||||
|
||||
@override
|
||||
void logout() => _me = null;
|
||||
|
||||
// ---- results -----------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<DrawInfo>> 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<DrawResults> results(DateTime date) async {
|
||||
await _t();
|
||||
final d = dateOnly(date);
|
||||
final scope = _scope();
|
||||
final rows = <ShopResult>[];
|
||||
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<List<ShopDay>> 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> 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<List<Shop>> activeShops() async {
|
||||
final scope = _scope();
|
||||
return _shops.where((s) => s.active && scope.contains(s.id)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shop> 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<void> 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<void> 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<List<Order>> 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> order(int orderId) async {
|
||||
await _t();
|
||||
return _toOrder(_scopedOrder(orderId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<void> cancelOrder(int orderId) async {
|
||||
await _t();
|
||||
_scopedOrder(orderId).cancelled = true;
|
||||
}
|
||||
|
||||
// ---- users -------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<AppUser>> 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<AppUser> 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<AppUser> createUser({
|
||||
required String username,
|
||||
String? phone,
|
||||
required String password,
|
||||
required List<int> 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<void> 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<void> deleteUser(int id) async {
|
||||
await _t();
|
||||
_admin();
|
||||
_editable(id);
|
||||
_users.removeWhere((u) => u.id == id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setUserShops(int id, List<int> shopIds) async {
|
||||
await _t();
|
||||
_admin();
|
||||
_editable(id).shops = List.of(shopIds);
|
||||
}
|
||||
}
|
||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
BIN
web/icons/Icon-192.png
Normal file
BIN
web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
web/icons/Icon-512.png
Normal file
BIN
web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web/icons/Icon-maskable-192.png
Normal file
BIN
web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
web/icons/Icon-maskable-512.png
Normal file
BIN
web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
47
web/index.html
Normal file
47
web/index.html
Normal file
@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="Your sequences, at a glance">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="SRS">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<meta name="theme-color" content="#1B2A49">
|
||||
<title>SRS</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
35
web/manifest.json
Normal file
35
web/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user