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.
168 lines
4.8 KiB
Dart
168 lines
4.8 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|