Question FIFO
This commit is contained in:
parent
609317bc35
commit
0c4c72f9c9
@ -5,10 +5,11 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Deterministic irregular polygon + palette derived from a question UUID.
|
/// Deterministic 3D crystal mesh + palette derived from a question UUID.
|
||||||
///
|
///
|
||||||
/// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to
|
/// Parses the canonical 16-byte UUID (hyphens optional), builds a perturbed
|
||||||
/// polar vertices so the same id always produces the same glyph.
|
/// icosahedron with optional stellated facets, and paints it with depth-sorted
|
||||||
|
/// shaded faces so the same id always produces the same glyph.
|
||||||
class GuidGlyphShape {
|
class GuidGlyphShape {
|
||||||
GuidGlyphShape._(this.bytes);
|
GuidGlyphShape._(this.bytes);
|
||||||
|
|
||||||
@ -23,25 +24,155 @@ class GuidGlyphShape {
|
|||||||
return GuidGlyphShape._(_hashTo16Bytes(guid));
|
return GuidGlyphShape._(_hashTo16Bytes(guid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vertex count in [5, 10], from the first UUID byte.
|
static const double _phi = 1.618033988749895;
|
||||||
int get vertexCount => 5 + (bytes[0] % 6);
|
|
||||||
|
|
||||||
/// Unit-circle offsets (rough polygon); multiply by size when painting.
|
static final List<_Vec3> _icosahedronBase = <_Vec3>[
|
||||||
List<Offset> unitVertices() {
|
_Vec3(-1, _phi, 0),
|
||||||
final int n = vertexCount;
|
_Vec3(1, _phi, 0),
|
||||||
final List<Offset> points = <Offset>[];
|
_Vec3(-1, -_phi, 0),
|
||||||
double angle = (bytes[1] / 255) * math.pi * 2;
|
_Vec3(1, -_phi, 0),
|
||||||
|
_Vec3(0, -1, _phi),
|
||||||
|
_Vec3(0, 1, _phi),
|
||||||
|
_Vec3(0, -1, -_phi),
|
||||||
|
_Vec3(0, 1, -_phi),
|
||||||
|
_Vec3(_phi, 0, -1),
|
||||||
|
_Vec3(_phi, 0, 1),
|
||||||
|
_Vec3(-_phi, 0, -1),
|
||||||
|
_Vec3(-_phi, 0, 1),
|
||||||
|
].map((v) => v.normalized(scale: 0.88)).toList(growable: false);
|
||||||
|
|
||||||
for (int i = 0; i < n; i++) {
|
static const List<List<int>> _icosahedronFaces = <List<int>>[
|
||||||
final int rIndex = (i * 2) % 16;
|
<int>[0, 11, 5],
|
||||||
final int aIndex = (i * 2 + 1) % 16;
|
<int>[0, 5, 1],
|
||||||
final double radius = 0.42 + (bytes[rIndex] / 255) * 0.52;
|
<int>[0, 1, 7],
|
||||||
angle += (math.pi * 2 / n) + ((bytes[aIndex] / 255) - 0.5) * 0.95;
|
<int>[0, 7, 10],
|
||||||
points.add(
|
<int>[0, 10, 11],
|
||||||
Offset(math.cos(angle) * radius, math.sin(angle) * radius),
|
<int>[1, 5, 9],
|
||||||
|
<int>[5, 11, 4],
|
||||||
|
<int>[11, 10, 2],
|
||||||
|
<int>[10, 7, 6],
|
||||||
|
<int>[7, 1, 8],
|
||||||
|
<int>[3, 9, 4],
|
||||||
|
<int>[3, 4, 2],
|
||||||
|
<int>[3, 2, 6],
|
||||||
|
<int>[3, 6, 8],
|
||||||
|
<int>[3, 8, 9],
|
||||||
|
<int>[4, 9, 5],
|
||||||
|
<int>[2, 4, 11],
|
||||||
|
<int>[6, 2, 10],
|
||||||
|
<int>[8, 6, 7],
|
||||||
|
<int>[9, 8, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
double _byte(int index) => bytes[index % 16] / 255;
|
||||||
|
|
||||||
|
/// Base orientation baked into every guid (before slider warp).
|
||||||
|
({double x, double y, double z}) get _baseEuler => (
|
||||||
|
x: (_byte(1) - 0.5) * math.pi * 0.9,
|
||||||
|
y: (_byte(2) - 0.5) * math.pi * 2,
|
||||||
|
z: (_byte(3) - 0.5) * math.pi * 0.55,
|
||||||
|
);
|
||||||
|
|
||||||
|
_Vec3 get _lightDirection => _Vec3(
|
||||||
|
0.25 + (_byte(10) - 0.5) * 0.5,
|
||||||
|
-0.45 + (_byte(11) - 0.5) * 0.35,
|
||||||
|
0.65 + (_byte(12) - 0.5) * 0.4,
|
||||||
|
).normalized();
|
||||||
|
|
||||||
|
/// Isometric projection mix from guid bytes.
|
||||||
|
({double xz, double lift}) get _projection => (
|
||||||
|
xz: 0.42 + _byte(13) * 0.22,
|
||||||
|
lift: 0.28 + _byte(14) * 0.18,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<_Vec3> _unitVerticesRaw() {
|
||||||
|
return List<_Vec3>.generate(12, (int i) {
|
||||||
|
final _Vec3 base = _icosahedronBase[i];
|
||||||
|
final double radial =
|
||||||
|
0.76 + _byte(i) * 0.42 + (_byte(i + 7) - 0.5) * 0.14;
|
||||||
|
final _Vec3 wobble = _Vec3(
|
||||||
|
(_byte(i + 3) - 0.5) * 0.22,
|
||||||
|
(_byte(i + 5) - 0.5) * 0.22,
|
||||||
|
(_byte(i + 9) - 0.5) * 0.22,
|
||||||
|
);
|
||||||
|
return (base + wobble).normalized(scale: radial);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_Vec3 _rotateEuler(_Vec3 v, ({double x, double y, double z}) euler) {
|
||||||
|
var out = v.rotateX(euler.x).rotateY(euler.y).rotateZ(euler.z);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellated icosahedron: some faces gain a pyramid apex for extra complexity.
|
||||||
|
({List<_Vec3> vertices, List<_MeshFace> faces}) _buildMesh(
|
||||||
|
List<_Vec3> vertices,
|
||||||
|
List<List<int>> triangles,
|
||||||
|
) {
|
||||||
|
final List<_Vec3> verts = List<_Vec3>.from(vertices);
|
||||||
|
final List<_MeshFace> faces = <_MeshFace>[
|
||||||
|
for (int fi = 0; fi < triangles.length; fi++)
|
||||||
|
_MeshFace(
|
||||||
|
fi,
|
||||||
|
triangles[fi][0],
|
||||||
|
triangles[fi][1],
|
||||||
|
triangles[fi][2],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (int fi = 0; fi < triangles.length; fi++) {
|
||||||
|
if ((bytes[(fi + 4) % 16] % 3) == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final List<int> tri = triangles[fi];
|
||||||
|
final _Vec3 v0 = verts[tri[0]];
|
||||||
|
final _Vec3 v1 = verts[tri[1]];
|
||||||
|
final _Vec3 v2 = verts[tri[2]];
|
||||||
|
final _Vec3 centroid = (v0 + v1 + v2) * (1 / 3);
|
||||||
|
final _Vec3 normal = _Vec3.cross(v1 - v0, v2 - v0).normalized();
|
||||||
|
final double spike = 0.18 + _byte(fi + 6) * 0.38;
|
||||||
|
final int apex = verts.length;
|
||||||
|
verts.add(centroid + normal * spike);
|
||||||
|
faces[fi] = _MeshFace(fi, tri[0], tri[1], apex);
|
||||||
|
faces.add(_MeshFace(fi + 100, tri[1], tri[2], apex));
|
||||||
|
faces.add(_MeshFace(fi + 200, tri[2], tri[0], apex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (vertices: verts, faces: faces);
|
||||||
|
}
|
||||||
|
|
||||||
|
({List<_Vec3> vertices, List<_MeshFace> faces}) displayMesh(num displayValue) {
|
||||||
|
final double t = displayT(displayValue);
|
||||||
|
final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0;
|
||||||
|
final ({double x, double y, double z}) euler = (
|
||||||
|
x: _baseEuler.x + t * (0.32 + _byte(4) * 0.28) * spinSign,
|
||||||
|
y: _baseEuler.y + t * (0.55 + _byte(5) * 0.35) * spinSign,
|
||||||
|
z: _baseEuler.z + t * (0.2 + _byte(6) * 0.18),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
|
||||||
|
_buildMesh(_unitVerticesRaw(), _icosahedronFaces);
|
||||||
|
|
||||||
|
final List<_Vec3> rotated = <_Vec3>[
|
||||||
|
for (final _Vec3 v in mesh.vertices) _rotateEuler(v, euler),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (vertices: rotated, faces: mesh.faces);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects a unit-space vertex to 2D (isometric-style).
|
||||||
|
Offset _projectUnit(_Vec3 v) {
|
||||||
|
final ({double xz, double lift}) proj = _projection;
|
||||||
|
return Offset(
|
||||||
|
v.x - v.z * proj.xz,
|
||||||
|
v.y + v.z * proj.lift + v.x * proj.lift * 0.12,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return points;
|
|
||||||
|
List<Offset> displayUnitVertices(num displayValue) {
|
||||||
|
final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
|
||||||
|
displayMesh(displayValue);
|
||||||
|
return mesh.vertices.map(_projectUnit).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fill color: warm accent band so glyphs stay on-brand but distinct.
|
/// Fill color: warm accent band so glyphs stay on-brand but distinct.
|
||||||
@ -50,41 +181,13 @@ class GuidGlyphShape {
|
|||||||
return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor();
|
return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor();
|
||||||
}
|
}
|
||||||
|
|
||||||
Color strokeColor() => HSLColor.fromColor(fillColor()).withLightness(0.38).toColor();
|
Color strokeColor() =>
|
||||||
|
HSLColor.fromColor(fillColor()).withLightness(0.38).toColor();
|
||||||
|
|
||||||
/// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10].
|
/// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10].
|
||||||
static double displayT(num displayValue) =>
|
static double displayT(num displayValue) =>
|
||||||
(displayValue / 10).clamp(-1.0, 1.0).toDouble();
|
(displayValue / 10).clamp(-1.0, 1.0).toDouble();
|
||||||
|
|
||||||
/// Display-only vertex warp; same [guid] + [displayValue] always matches.
|
|
||||||
List<Offset> displayUnitVertices(num displayValue) {
|
|
||||||
final double t = displayT(displayValue);
|
|
||||||
final List<Offset> base = unitVertices();
|
|
||||||
final int n = base.length;
|
|
||||||
|
|
||||||
final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0;
|
|
||||||
final double spin = t *
|
|
||||||
math.pi *
|
|
||||||
(0.28 + (bytes[4] / 255) * 0.22) *
|
|
||||||
spinSign;
|
|
||||||
final double cosR = math.cos(spin);
|
|
||||||
final double sinR = math.sin(spin);
|
|
||||||
|
|
||||||
final double stretchY = 1 + t * (0.38 + (bytes[5] / 255) * 0.2);
|
|
||||||
final double stretchX = 1 - t * 0.14 * (bytes[6] / 255);
|
|
||||||
|
|
||||||
final List<Offset> out = <Offset>[];
|
|
||||||
for (int i = 0; i < n; i++) {
|
|
||||||
final Offset p = base[i];
|
|
||||||
final double bulge =
|
|
||||||
1 + t * ((bytes[(i * 3 + 7) % 16] / 255) - 0.5) * 0.55;
|
|
||||||
final double x = p.dx * stretchX * bulge;
|
|
||||||
final double y = p.dy * stretchY * bulge;
|
|
||||||
out.add(Offset(x * cosR - y * sinR, x * sinR + y * cosR));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the slider is at neutral zero (no directional guess).
|
/// True when the slider is at neutral zero (no directional guess).
|
||||||
static bool isNeutralZero(num displayValue) => displayValue == 0;
|
static bool isNeutralZero(num displayValue) => displayValue == 0;
|
||||||
|
|
||||||
@ -116,7 +219,22 @@ class GuidGlyphShape {
|
|||||||
return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!;
|
return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Furthest unit-circle distance after [displayUnitVertices] warp (for glow sizing).
|
Color _shadeFaceColor(Color base, double shade, num displayValue) {
|
||||||
|
if (isNeutralZero(displayValue)) {
|
||||||
|
return Color.lerp(
|
||||||
|
const Color(0xFFE8EEF4),
|
||||||
|
const Color(0xFFF8FCFF),
|
||||||
|
shade,
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
final HSLColor hsl = HSLColor.fromColor(base);
|
||||||
|
return hsl
|
||||||
|
.withLightness((hsl.lightness * (0.55 + shade * 0.5)).clamp(0.18, 0.82))
|
||||||
|
.withSaturation((hsl.saturation * (0.75 + shade * 0.35)).clamp(0.2, 1))
|
||||||
|
.toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Furthest 2D extent after projection (for glow sizing).
|
||||||
double displayMaxUnitRadius(num displayValue) {
|
double displayMaxUnitRadius(num displayValue) {
|
||||||
double maxR = 0;
|
double maxR = 0;
|
||||||
for (final Offset p in displayUnitVertices(displayValue)) {
|
for (final Offset p in displayUnitVertices(displayValue)) {
|
||||||
@ -135,7 +253,7 @@ class GuidGlyphShape {
|
|||||||
|
|
||||||
double displayStrokeWidth(num displayValue) {
|
double displayStrokeWidth(num displayValue) {
|
||||||
final double t = displayT(displayValue);
|
final double t = displayT(displayValue);
|
||||||
return (2.5 + t.abs() * 1.2 + (bytes[8] / 255) * 0.4).clamp(2.0, 4.0);
|
return (1.8 + t.abs() * 0.9 + (bytes[8] / 255) * 0.35).clamp(1.4, 3.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Uint8List? tryParseUuidBytes(String guid) {
|
static Uint8List? tryParseUuidBytes(String guid) {
|
||||||
@ -166,6 +284,76 @@ class GuidGlyphShape {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _Vec3 {
|
||||||
|
const _Vec3(this.x, this.y, this.z);
|
||||||
|
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
final double z;
|
||||||
|
|
||||||
|
double get length => math.sqrt(x * x + y * y + z * z);
|
||||||
|
|
||||||
|
_Vec3 normalized({double scale = 1}) {
|
||||||
|
final double len = length;
|
||||||
|
if (len < 1e-9) {
|
||||||
|
return _Vec3(0, scale, 0);
|
||||||
|
}
|
||||||
|
final double s = scale / len;
|
||||||
|
return _Vec3(x * s, y * s, z * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
_Vec3 operator +(_Vec3 other) => _Vec3(x + other.x, y + other.y, z + other.z);
|
||||||
|
|
||||||
|
_Vec3 operator -(_Vec3 other) => _Vec3(x - other.x, y - other.y, z - other.z);
|
||||||
|
|
||||||
|
_Vec3 operator *(double scalar) => _Vec3(x * scalar, y * scalar, z * scalar);
|
||||||
|
|
||||||
|
_Vec3 rotateX(double angle) {
|
||||||
|
final double c = math.cos(angle);
|
||||||
|
final double s = math.sin(angle);
|
||||||
|
return _Vec3(x, y * c - z * s, y * s + z * c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_Vec3 rotateY(double angle) {
|
||||||
|
final double c = math.cos(angle);
|
||||||
|
final double s = math.sin(angle);
|
||||||
|
return _Vec3(x * c + z * s, y, -x * s + z * c);
|
||||||
|
}
|
||||||
|
|
||||||
|
_Vec3 rotateZ(double angle) {
|
||||||
|
final double c = math.cos(angle);
|
||||||
|
final double s = math.sin(angle);
|
||||||
|
return _Vec3(x * c - y * s, x * s + y * c, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double dot(_Vec3 a, _Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||||
|
|
||||||
|
static _Vec3 cross(_Vec3 a, _Vec3 b) => _Vec3(
|
||||||
|
a.y * b.z - a.z * b.y,
|
||||||
|
a.z * b.x - a.x * b.z,
|
||||||
|
a.x * b.y - a.y * b.x,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MeshFace {
|
||||||
|
const _MeshFace(this.id, this.i0, this.i1, this.i2);
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final int i0;
|
||||||
|
final int i1;
|
||||||
|
final int i2;
|
||||||
|
|
||||||
|
double averageDepth(List<_Vec3> vertices) =>
|
||||||
|
(vertices[i0].z + vertices[i1].z + vertices[i2].z) / 3;
|
||||||
|
|
||||||
|
_Vec3 normal(List<_Vec3> vertices) {
|
||||||
|
final _Vec3 v0 = vertices[i0];
|
||||||
|
final _Vec3 v1 = vertices[i1];
|
||||||
|
final _Vec3 v2 = vertices[i2];
|
||||||
|
return _Vec3.cross(v1 - v0, v2 - v0).normalized();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Painted glyph for a question id (replaces the fixed red dot).
|
/// Painted glyph for a question id (replaces the fixed red dot).
|
||||||
class QuestionGuidGlyph extends StatelessWidget {
|
class QuestionGuidGlyph extends StatelessWidget {
|
||||||
const QuestionGuidGlyph({
|
const QuestionGuidGlyph({
|
||||||
@ -188,10 +376,8 @@ class QuestionGuidGlyph extends StatelessWidget {
|
|||||||
final Color glow = shape.displayGlowColor(displayValue);
|
final Color glow = shape.displayGlowColor(displayValue);
|
||||||
final double strokeWidth = shape.displayStrokeWidth(displayValue);
|
final double strokeWidth = shape.displayStrokeWidth(displayValue);
|
||||||
final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2);
|
final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2);
|
||||||
// Slightly larger bounds when warped / off-zero so the halo follows the shape.
|
final double paintedRadius = baseRadius * (1 + intensity * 0.12);
|
||||||
final double paintedRadius = baseRadius * (1 + intensity * 0.1);
|
|
||||||
|
|
||||||
// Shadow box matches painted extent; blur/spread grow with that diameter.
|
|
||||||
final double bodyDiameter = paintedRadius * 2;
|
final double bodyDiameter = paintedRadius * 2;
|
||||||
final double blur = bodyDiameter * (0.2 + intensity * 0.16);
|
final double blur = bodyDiameter * (0.2 + intensity * 0.16);
|
||||||
final double spread = bodyDiameter * (0.03 + intensity * 0.06);
|
final double spread = bodyDiameter * (0.03 + intensity * 0.06);
|
||||||
@ -250,36 +436,94 @@ class _GuidGlyphPainter extends CustomPainter {
|
|||||||
final double maxUnitR = shape.displayMaxUnitRadius(displayValue);
|
final double maxUnitR = shape.displayMaxUnitRadius(displayValue);
|
||||||
final double scale = paintedRadius / maxUnitR;
|
final double scale = paintedRadius / maxUnitR;
|
||||||
final double strokeWidth = shape.displayStrokeWidth(displayValue);
|
final double strokeWidth = shape.displayStrokeWidth(displayValue);
|
||||||
final List<Offset> unit = shape.displayUnitVertices(displayValue);
|
|
||||||
|
|
||||||
final Path path = Path();
|
final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh =
|
||||||
for (int i = 0; i < unit.length; i++) {
|
shape.displayMesh(displayValue);
|
||||||
final Offset p = center + Offset(unit[i].dx * scale, unit[i].dy * scale);
|
final List<Offset> projected = mesh.vertices
|
||||||
if (i == 0) {
|
.map((v) => center + shape._projectUnit(v) * scale)
|
||||||
path.moveTo(p.dx, p.dy);
|
.toList(growable: false);
|
||||||
} else {
|
|
||||||
path.lineTo(p.dx, p.dy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
path.close();
|
|
||||||
|
|
||||||
final Color fill = shape.displayFillColor(displayValue);
|
final List<_MeshFace> sorted = List<_MeshFace>.from(mesh.faces)
|
||||||
final Color stroke = shape.displayStrokeColor(displayValue);
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.averageDepth(mesh.vertices).compareTo(b.averageDepth(mesh.vertices)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Color baseFill = shape.displayFillColor(displayValue);
|
||||||
|
final _Vec3 light = shape._lightDirection;
|
||||||
|
|
||||||
|
for (final _MeshFace face in sorted) {
|
||||||
|
final _Vec3 normal = face.normal(mesh.vertices);
|
||||||
|
final double ndotl =
|
||||||
|
_Vec3.dot(normal, light).clamp(-1.0, 1.0) * 0.5 + 0.5;
|
||||||
|
final double shade = (0.28 + ndotl * 0.72).clamp(0.22, 1.0);
|
||||||
|
final Path path = Path()
|
||||||
|
..moveTo(projected[face.i0].dx, projected[face.i0].dy)
|
||||||
|
..lineTo(projected[face.i1].dx, projected[face.i1].dy)
|
||||||
|
..lineTo(projected[face.i2].dx, projected[face.i2].dy)
|
||||||
|
..close();
|
||||||
|
|
||||||
canvas.drawPath(
|
canvas.drawPath(
|
||||||
path,
|
path,
|
||||||
Paint()
|
Paint()
|
||||||
..color = fill
|
..color = shape._shadeFaceColor(baseFill, shade, displayValue)
|
||||||
..style = PaintingStyle.fill,
|
..style = PaintingStyle.fill,
|
||||||
);
|
);
|
||||||
canvas.drawPath(
|
}
|
||||||
path,
|
|
||||||
Paint()
|
final Color edgeColor = shape.displayStrokeColor(displayValue);
|
||||||
..color = stroke
|
final Paint edgePaint = Paint()
|
||||||
|
..color = edgeColor.withValues(alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.55 : 0.75)
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth
|
..strokeWidth = strokeWidth
|
||||||
..strokeJoin = StrokeJoin.round,
|
..strokeJoin = StrokeJoin.round;
|
||||||
|
|
||||||
|
final Set<String> drawnEdges = <String>{};
|
||||||
|
for (final _MeshFace face in mesh.faces) {
|
||||||
|
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i0, face.i1);
|
||||||
|
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i1, face.i2);
|
||||||
|
_strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i2, face.i0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specular highlight on the front-most face.
|
||||||
|
if (sorted.isNotEmpty) {
|
||||||
|
final _MeshFace front = sorted.last;
|
||||||
|
final Offset highlight = Offset(
|
||||||
|
(projected[front.i0].dx +
|
||||||
|
projected[front.i1].dx +
|
||||||
|
projected[front.i2].dx) /
|
||||||
|
3,
|
||||||
|
(projected[front.i0].dy +
|
||||||
|
projected[front.i1].dy +
|
||||||
|
projected[front.i2].dy) /
|
||||||
|
3,
|
||||||
);
|
);
|
||||||
|
canvas.drawCircle(
|
||||||
|
highlight,
|
||||||
|
(scale * 0.09).clamp(2.0, 6.0),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white.withValues(
|
||||||
|
alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.35 : 0.28,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _strokeEdge(
|
||||||
|
Canvas canvas,
|
||||||
|
List<Offset> projected,
|
||||||
|
Paint paint,
|
||||||
|
Set<String> drawnEdges,
|
||||||
|
int a,
|
||||||
|
int b,
|
||||||
|
) {
|
||||||
|
final int lo = a < b ? a : b;
|
||||||
|
final int hi = a < b ? b : a;
|
||||||
|
final String key = '$lo-$hi';
|
||||||
|
if (!drawnEdges.add(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvas.drawLine(projected[lo], projected[hi], paint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
12
lib/models/question_defer_result.dart
Normal file
12
lib/models/question_defer_result.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'incoming_question.dart';
|
||||||
|
|
||||||
|
/// Outcome of deferring (skipping) a question to the back of the FIFO queue.
|
||||||
|
class QuestionDeferResult {
|
||||||
|
const QuestionDeferResult({
|
||||||
|
required this.unansweredCount,
|
||||||
|
this.nextQuestion,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int unansweredCount;
|
||||||
|
final IncomingQuestion? nextQuestion;
|
||||||
|
}
|
||||||
@ -279,7 +279,7 @@ class _QuestionEnvelopeButton extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
tooltip: count > 1 ? '$count questions' : 'New question',
|
tooltip: count == 1 ? '1 question' : '$count questions',
|
||||||
icon: const Icon(Icons.mail_outline, size: 22),
|
icon: const Icon(Icons.mail_outline, size: 22),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: AppColors.accent.withValues(alpha: 0.15),
|
backgroundColor: AppColors.accent.withValues(alpha: 0.15),
|
||||||
@ -289,7 +289,7 @@ class _QuestionEnvelopeButton extends StatelessWidget {
|
|||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (count >= 2)
|
if (count >= 1)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
right: 0,
|
right: 0,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../config/api_config.dart';
|
import '../config/api_config.dart';
|
||||||
import '../models/guess_score_summary.dart';
|
import '../models/guess_score_summary.dart';
|
||||||
import '../models/incoming_question.dart';
|
import '../models/incoming_question.dart';
|
||||||
|
import '../models/question_defer_result.dart';
|
||||||
import '../models/question_submit_result.dart';
|
import '../models/question_submit_result.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
||||||
@ -120,7 +121,7 @@ class QuestionsApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> deferQuestion({required String questionId}) async {
|
Future<QuestionDeferResult?> deferQuestion({required String questionId}) async {
|
||||||
final String? token = await AuthService.instance.getIdToken();
|
final String? token = await AuthService.instance.getIdToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return null;
|
return null;
|
||||||
@ -139,7 +140,14 @@ class QuestionsApiService {
|
|||||||
|
|
||||||
final Map<String, dynamic> body =
|
final Map<String, dynamic> body =
|
||||||
jsonDecode(response.body) as Map<String, dynamic>;
|
jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
return (body['unansweredCount'] as num?)?.toInt();
|
final Map<String, dynamic>? nextQuestionJson =
|
||||||
|
body['nextQuestion'] as Map<String, dynamic>?;
|
||||||
|
return QuestionDeferResult(
|
||||||
|
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
|
||||||
|
nextQuestion: nextQuestionJson == null
|
||||||
|
? null
|
||||||
|
: IncomingQuestion.fromJson(nextQuestionJson),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GuessScoreSummary?> fetchGuessScoreSummary() async {
|
Future<GuessScoreSummary?> fetchGuessScoreSummary() async {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:signalr_netcore/signalr_client.dart';
|
|||||||
import '../config/api_config.dart';
|
import '../config/api_config.dart';
|
||||||
import '../models/guess_score_summary.dart';
|
import '../models/guess_score_summary.dart';
|
||||||
import '../models/incoming_question.dart';
|
import '../models/incoming_question.dart';
|
||||||
|
import '../models/question_defer_result.dart';
|
||||||
import '../models/question_submit_result.dart';
|
import '../models/question_submit_result.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
import 'questions_api_service.dart';
|
import 'questions_api_service.dart';
|
||||||
@ -173,26 +174,50 @@ class QuestionsHubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String skippedQuestionId = question.id;
|
||||||
questionActionBusy.value = true;
|
questionActionBusy.value = true;
|
||||||
try {
|
try {
|
||||||
final List<IncomingQuestion> queue = List<IncomingQuestion>.from(
|
final QuestionDeferResult? result = await _api.deferQuestion(
|
||||||
questionQueue.value,
|
questionId: skippedQuestionId,
|
||||||
);
|
);
|
||||||
if (queue.isEmpty) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final IncomingQuestion current = queue.removeAt(0);
|
|
||||||
queue.add(current);
|
|
||||||
|
|
||||||
final int? serverCount = await _api.deferQuestion(questionId: current.id);
|
if (result.unansweredCount == 0) {
|
||||||
if (serverCount != null) {
|
_clearPendingUi();
|
||||||
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
|
return;
|
||||||
questionQueue.value = refreshed.isNotEmpty ? refreshed : queue;
|
|
||||||
_syncPendingFromQueue(serverCount);
|
|
||||||
} else {
|
|
||||||
questionQueue.value = queue;
|
|
||||||
_syncPendingFromQueue(queue.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
|
||||||
|
if (refreshed.isEmpty) {
|
||||||
|
if (result.nextQuestion != null) {
|
||||||
|
_setActiveQuestion(
|
||||||
|
result.nextQuestion!,
|
||||||
|
unansweredCount: result.unansweredCount,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_clearPendingUi();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IncomingQuestion next = refreshed.first;
|
||||||
|
if (refreshed.length > 1 && next.id == skippedQuestionId) {
|
||||||
|
next = refreshed.firstWhere(
|
||||||
|
(IncomingQuestion q) => q.id != skippedQuestionId,
|
||||||
|
orElse: () => refreshed.first,
|
||||||
|
);
|
||||||
|
} else if (result.nextQuestion != null &&
|
||||||
|
result.nextQuestion!.id != skippedQuestionId) {
|
||||||
|
next = result.nextQuestion!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setActiveQuestion(
|
||||||
|
next,
|
||||||
|
unansweredCount: result.unansweredCount,
|
||||||
|
queue: refreshed,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
questionActionBusy.value = false;
|
questionActionBusy.value = false;
|
||||||
}
|
}
|
||||||
@ -283,17 +308,6 @@ class QuestionsHubService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncPendingFromQueue(int count) {
|
|
||||||
final List<IncomingQuestion> queue = questionQueue.value;
|
|
||||||
if (queue.isEmpty || count == 0) {
|
|
||||||
_clearPendingUi();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingQuestion.value = queue.first;
|
|
||||||
pendingQuestionCount.value = count;
|
|
||||||
hasPendingQuestion.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears pending question UI; keeps the SignalR connection alive.
|
/// Clears pending question UI; keeps the SignalR connection alive.
|
||||||
void _clearPendingUi() {
|
void _clearPendingUi() {
|
||||||
hasPendingQuestion.value = false;
|
hasPendingQuestion.value = false;
|
||||||
|
|||||||
@ -8,6 +8,11 @@ import 'package:flutter/services.dart';
|
|||||||
import '../guid/guid_glyph_shape.dart';
|
import '../guid/guid_glyph_shape.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
const double _holdEdgeButtonWidth = 58;
|
||||||
|
|
||||||
|
/// Equal left/right margin for the slider; edge buttons sit in the gutters.
|
||||||
|
const double _centerSideInset = _holdEdgeButtonWidth + 8;
|
||||||
|
|
||||||
/// Swipeable question card: right submits, left defers (does not resolve).
|
/// Swipeable question card: right submits, left defers (does not resolve).
|
||||||
///
|
///
|
||||||
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
|
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
|
||||||
@ -30,14 +35,19 @@ class SwipeQuestionTile extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
double _verticalOffset = 0;
|
double _verticalOffset = 0;
|
||||||
bool _acting = false;
|
bool _acting = false;
|
||||||
|
bool _holdSavePointerDown = false;
|
||||||
|
bool _holdDeferPointerDown = false;
|
||||||
int _lastSnappedValue = 0;
|
int _lastSnappedValue = 0;
|
||||||
late final AnimationController _snapController;
|
late final AnimationController _snapController;
|
||||||
|
late final AnimationController _holdSaveController;
|
||||||
|
late final AnimationController _holdDeferController;
|
||||||
|
|
||||||
static const double _swipeThreshold = 96;
|
static const double _swipeThreshold = 96;
|
||||||
|
static const Duration _holdEdgeDuration = Duration(seconds: 1);
|
||||||
static const double _glyphSize = 80;
|
static const double _glyphSize = 80;
|
||||||
static const double _trackEdgeInset = 8;
|
static const double _trackEdgeInset = 8;
|
||||||
static const int _sliderMin = -10;
|
static const int _sliderMin = -10;
|
||||||
@ -48,7 +58,20 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
|
|
||||||
bool get _atZero => _snappedSliderValue == 0;
|
bool get _atZero => _snappedSliderValue == 0;
|
||||||
|
|
||||||
bool get _horizontalSwipeEnabled => !widget.busy && !_acting && !_atZero;
|
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
|
||||||
|
|
||||||
|
bool get _holdDeferEnabled => !widget.busy && !_acting;
|
||||||
|
|
||||||
|
bool get _holdSaveActive =>
|
||||||
|
_holdSavePointerDown || _holdSaveController.value > 0;
|
||||||
|
|
||||||
|
bool get _holdDeferActive =>
|
||||||
|
_holdDeferPointerDown || _holdDeferController.value > 0;
|
||||||
|
|
||||||
|
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
|
||||||
|
|
||||||
|
bool get _horizontalSwipeEnabled =>
|
||||||
|
_holdSaveEnabled && !_holdEdgeActive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -57,14 +80,160 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: const Duration(milliseconds: 140),
|
||||||
);
|
);
|
||||||
|
_holdSaveController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: _holdEdgeDuration,
|
||||||
|
)..addListener(_syncHoldSaveDrag)
|
||||||
|
..addStatusListener(_onHoldSaveStatus);
|
||||||
|
_holdDeferController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: _holdEdgeDuration,
|
||||||
|
)..addListener(_syncHoldDeferDrag)
|
||||||
|
..addStatusListener(_onHoldDeferStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_snapController.dispose();
|
_snapController.dispose();
|
||||||
|
_holdSaveController.dispose();
|
||||||
|
_holdDeferController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncHoldSaveDrag() {
|
||||||
|
if (!mounted || _acting || _holdDeferActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final double width = MediaQuery.sizeOf(context).width;
|
||||||
|
final double next = _holdSaveController.value * width;
|
||||||
|
if (next == _dragOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _dragOffset = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncHoldDeferDrag() {
|
||||||
|
if (!mounted || _acting || _holdSaveActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final double width = MediaQuery.sizeOf(context).width;
|
||||||
|
final double next = -_holdDeferController.value * width;
|
||||||
|
if (next == _dragOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _dragOffset = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldEdgeDismissed() {
|
||||||
|
if (mounted && !_acting) {
|
||||||
|
setState(() => _dragOffset = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldSaveStatus(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.dismissed) {
|
||||||
|
if (!_holdDeferActive) {
|
||||||
|
_onHoldEdgeDismissed();
|
||||||
|
}
|
||||||
|
} else if (status == AnimationStatus.completed &&
|
||||||
|
_holdSavePointerDown &&
|
||||||
|
mounted) {
|
||||||
|
_holdSavePointerDown = false;
|
||||||
|
unawaited(_completeHoldSave());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldDeferStatus(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.dismissed) {
|
||||||
|
if (!_holdSaveActive) {
|
||||||
|
_onHoldEdgeDismissed();
|
||||||
|
}
|
||||||
|
} else if (status == AnimationStatus.completed &&
|
||||||
|
_holdDeferPointerDown &&
|
||||||
|
mounted) {
|
||||||
|
_holdDeferPointerDown = false;
|
||||||
|
unawaited(_completeHoldDefer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldSavePointerDown(PointerDownEvent event) {
|
||||||
|
if (!_holdSaveEnabled || _holdDeferActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_holdSavePointerDown = true;
|
||||||
|
_holdSaveController.forward(from: _holdSaveController.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldSavePointerUp(PointerEvent event) {
|
||||||
|
if (!_holdSavePointerDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_holdSavePointerDown = false;
|
||||||
|
if (_holdSaveController.value >= 1.0) {
|
||||||
|
unawaited(_completeHoldSave());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_holdSaveController.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completeHoldSave() async {
|
||||||
|
if (_acting || widget.busy || _atZero || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final double width = MediaQuery.sizeOf(context).width;
|
||||||
|
setState(() {
|
||||||
|
_acting = true;
|
||||||
|
_dragOffset = width;
|
||||||
|
});
|
||||||
|
await widget.onSwipeRight(_snappedSliderValue);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_acting = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
_holdSaveController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldDeferPointerDown(PointerDownEvent event) {
|
||||||
|
if (!_holdDeferEnabled || _holdSaveActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_holdDeferPointerDown = true;
|
||||||
|
_holdDeferController.forward(from: _holdDeferController.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHoldDeferPointerUp(PointerEvent event) {
|
||||||
|
if (!_holdDeferPointerDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_holdDeferPointerDown = false;
|
||||||
|
if (_holdDeferController.value >= 1.0) {
|
||||||
|
unawaited(_completeHoldDefer());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_holdDeferController.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _completeHoldDefer() async {
|
||||||
|
if (_acting || widget.busy || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final double width = MediaQuery.sizeOf(context).width;
|
||||||
|
setState(() {
|
||||||
|
_acting = true;
|
||||||
|
_dragOffset = -width;
|
||||||
|
});
|
||||||
|
await widget.onSwipeLeft();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_acting = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
_holdDeferController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Swipe up → +10, swipe down → -10; snaps to whole numbers.
|
/// Swipe up → +10, swipe down → -10; snaps to whole numbers.
|
||||||
int get _snappedSliderValue =>
|
int get _snappedSliderValue =>
|
||||||
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
|
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
|
||||||
@ -234,8 +403,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
left: constraints.maxWidth * 0.22,
|
left: _centerSideInset,
|
||||||
right: constraints.maxWidth * 0.22,
|
right: _centerSideInset,
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
@ -248,8 +417,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
left: constraints.maxWidth * 0.22,
|
left: _centerSideInset,
|
||||||
right: constraints.maxWidth * 0.22,
|
right: _centerSideInset,
|
||||||
child: Listener(
|
child: Listener(
|
||||||
onPointerSignal: (PointerSignalEvent event) {
|
onPointerSignal: (PointerSignalEvent event) {
|
||||||
if (widget.busy || _acting) {
|
if (widget.busy || _acting) {
|
||||||
@ -283,6 +452,45 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
child: _HoldSwipeEdgeButton(
|
||||||
|
side: _HoldSwipeEdgeSide.left,
|
||||||
|
enabled: _holdDeferEnabled,
|
||||||
|
progress: _holdDeferController.value,
|
||||||
|
active: _holdDeferPointerDown,
|
||||||
|
icon: Icons.reply,
|
||||||
|
ringColor: AppColors.accent,
|
||||||
|
tooltipEnabled: 'Hold to defer question',
|
||||||
|
tooltipDisabled: 'Unavailable while busy',
|
||||||
|
semanticsLabel: 'Hold to defer question',
|
||||||
|
onPointerDown: _onHoldDeferPointerDown,
|
||||||
|
onPointerUp: _onHoldDeferPointerUp,
|
||||||
|
onPointerCancel: _onHoldDeferPointerUp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
child: _HoldSwipeEdgeButton(
|
||||||
|
side: _HoldSwipeEdgeSide.right,
|
||||||
|
enabled: _holdSaveEnabled,
|
||||||
|
progress: _holdSaveController.value,
|
||||||
|
active: _holdSavePointerDown,
|
||||||
|
icon: Icons.save,
|
||||||
|
ringColor: AppColors.success,
|
||||||
|
tooltipEnabled: 'Hold to save answer',
|
||||||
|
tooltipDisabled:
|
||||||
|
'Set an answer before saving',
|
||||||
|
semanticsLabel: 'Hold to save answer',
|
||||||
|
onPointerDown: _onHoldSavePointerDown,
|
||||||
|
onPointerUp: _onHoldSavePointerUp,
|
||||||
|
onPointerCancel: _onHoldSavePointerUp,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -307,3 +515,119 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _HoldSwipeEdgeSide { left, right }
|
||||||
|
|
||||||
|
/// Edge control: hold ~1s to sweep the tile off-screen.
|
||||||
|
class _HoldSwipeEdgeButton extends StatelessWidget {
|
||||||
|
const _HoldSwipeEdgeButton({
|
||||||
|
required this.side,
|
||||||
|
required this.enabled,
|
||||||
|
required this.progress,
|
||||||
|
required this.active,
|
||||||
|
required this.icon,
|
||||||
|
required this.ringColor,
|
||||||
|
required this.tooltipEnabled,
|
||||||
|
required this.tooltipDisabled,
|
||||||
|
required this.semanticsLabel,
|
||||||
|
required this.onPointerDown,
|
||||||
|
required this.onPointerUp,
|
||||||
|
required this.onPointerCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _HoldSwipeEdgeSide side;
|
||||||
|
final bool enabled;
|
||||||
|
final double progress;
|
||||||
|
final bool active;
|
||||||
|
final IconData icon;
|
||||||
|
final Color ringColor;
|
||||||
|
final String tooltipEnabled;
|
||||||
|
final String tooltipDisabled;
|
||||||
|
final String semanticsLabel;
|
||||||
|
final void Function(PointerDownEvent event) onPointerDown;
|
||||||
|
final void Function(PointerEvent event) onPointerUp;
|
||||||
|
final void Function(PointerEvent event) onPointerCancel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Color iconColor = enabled
|
||||||
|
? AppColors.textPrimary.withValues(alpha: active ? 1 : 0.85)
|
||||||
|
: AppColors.textSecondary.withValues(alpha: 0.35);
|
||||||
|
final Color progressColor = enabled
|
||||||
|
? ringColor.withValues(alpha: active ? 0.95 : 0.7)
|
||||||
|
: AppColors.textSecondary.withValues(alpha: 0.2);
|
||||||
|
final bool onLeft = side == _HoldSwipeEdgeSide.left;
|
||||||
|
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: enabled ? onPointerDown : null,
|
||||||
|
onPointerUp: enabled ? onPointerUp : null,
|
||||||
|
onPointerCancel: enabled ? onPointerCancel : null,
|
||||||
|
child: Tooltip(
|
||||||
|
message: enabled ? tooltipEnabled : tooltipDisabled,
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: enabled,
|
||||||
|
label: semanticsLabel,
|
||||||
|
child: Container(
|
||||||
|
width: _holdEdgeButtonWidth,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface.withValues(alpha: enabled ? 0.92 : 0.72),
|
||||||
|
borderRadius: BorderRadius.horizontal(
|
||||||
|
left: onLeft ? Radius.zero : const Radius.circular(12),
|
||||||
|
right: onLeft ? const Radius.circular(12) : Radius.zero,
|
||||||
|
),
|
||||||
|
border: Border(
|
||||||
|
left: onLeft
|
||||||
|
? BorderSide.none
|
||||||
|
: BorderSide(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.14),
|
||||||
|
),
|
||||||
|
right: onLeft
|
||||||
|
? BorderSide(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.14),
|
||||||
|
)
|
||||||
|
: BorderSide.none,
|
||||||
|
),
|
||||||
|
boxShadow: <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.32),
|
||||||
|
offset: Offset(onLeft ? 4 : -4, 0),
|
||||||
|
blurRadius: 12,
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.14),
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
blurRadius: 6,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
if (progress > 0)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
backgroundColor: progressColor.withValues(alpha: 0.2),
|
||||||
|
color: progressColor,
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -139,7 +139,18 @@ Handler questionsHandler({
|
|||||||
nextQuestion =
|
nextQuestion =
|
||||||
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
|
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
|
||||||
if (nextQuestion != null) {
|
if (nextQuestion != null) {
|
||||||
unansweredCount = 1;
|
unansweredCount =
|
||||||
|
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
||||||
|
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final Map<String, dynamic>? nextRow =
|
||||||
|
await questionsDb.findUnansweredQuestion(firebaseUid);
|
||||||
|
if (nextRow != null) {
|
||||||
|
nextQuestion = questionsDb.toClientPayload(
|
||||||
|
nextRow,
|
||||||
|
unansweredCount: unansweredCount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final Map<String, dynamic> score =
|
final Map<String, dynamic> score =
|
||||||
@ -174,9 +185,21 @@ Handler questionsHandler({
|
|||||||
}
|
}
|
||||||
final int unansweredCount =
|
final int unansweredCount =
|
||||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
|
final Map<String, dynamic>? nextRow =
|
||||||
|
await questionsDb.findNextUnansweredAfterDefer(
|
||||||
|
assignedUserId: firebaseUid,
|
||||||
|
deferredQuestionId: id,
|
||||||
|
);
|
||||||
|
final Map<String, dynamic>? nextQuestion = nextRow == null
|
||||||
|
? null
|
||||||
|
: questionsDb.toClientPayload(
|
||||||
|
nextRow,
|
||||||
|
unansweredCount: unansweredCount,
|
||||||
|
);
|
||||||
return _jsonResponse(200, <String, dynamic>{
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
'question': updated,
|
'question': updated,
|
||||||
'unansweredCount': unansweredCount,
|
'unansweredCount': unansweredCount,
|
||||||
|
if (nextQuestion != null) 'nextQuestion': nextQuestion,
|
||||||
});
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
stderr.writeln('Defer question error: $e\n$st');
|
stderr.writeln('Defer question error: $e\n$st');
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'questions_db.dart';
|
import 'questions_db.dart';
|
||||||
import 'signalr/questions_hub_connections.dart';
|
import 'signalr/questions_hub_connections.dart';
|
||||||
|
import 'trading/prospective_guess_selection.dart';
|
||||||
import 'trading/prospective_guess_assignments_db.dart';
|
import 'trading/prospective_guess_assignments_db.dart';
|
||||||
|
import 'trading/market_history_question_audit.dart';
|
||||||
|
|
||||||
/// Creates questions in Postgres and delivers them over SignalR.
|
/// Creates questions in Postgres and delivers them over SignalR.
|
||||||
class QuestionService {
|
class QuestionService {
|
||||||
@ -27,29 +29,13 @@ class QuestionService {
|
|||||||
return ensureProspectiveQuestionQueued(firebaseUid);
|
return ensureProspectiveQuestionQueued(firebaseUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the next slot-based prospective question when the queue is empty.
|
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
|
||||||
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
|
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
|
||||||
String firebaseUid, {
|
String firebaseUid, {
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
}) async {
|
}) async {
|
||||||
await _questionsDb.ensureUserExists(firebaseUid);
|
await _questionsDb.ensureUserExists(firebaseUid);
|
||||||
|
await _ensureCurrentSlotBatch(firebaseUid, now: now);
|
||||||
final String? pendingQuestionId =
|
|
||||||
await _assignmentsDb.findPendingQuestionId(firebaseUid);
|
|
||||||
if (pendingQuestionId != null) {
|
|
||||||
final Map<String, dynamic>? question = await _questionsDb.findQuestionById(
|
|
||||||
questionId: pendingQuestionId,
|
|
||||||
assignedUserId: firebaseUid,
|
|
||||||
);
|
|
||||||
if (question != null && question['userResponse'] == null) {
|
|
||||||
final int unansweredCount =
|
|
||||||
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
|
||||||
return _questionsDb.toClientPayload(
|
|
||||||
question,
|
|
||||||
unansweredCount: unansweredCount > 0 ? unansweredCount : 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final int queued =
|
final int queued =
|
||||||
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
await _questionsDb.countUnansweredQuestions(firebaseUid);
|
||||||
@ -65,52 +51,45 @@ class QuestionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, dynamic>? prospective =
|
|
||||||
await _createProspectiveQuestionWithAssignment(
|
|
||||||
firebaseUid,
|
|
||||||
now: now,
|
|
||||||
);
|
|
||||||
if (prospective == null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return prospective;
|
/// Creates every top-half question for the user's active slot pair.
|
||||||
}
|
Future<void> _ensureCurrentSlotBatch(
|
||||||
|
|
||||||
/// Picks, creates, and records one prospective question when none is queued.
|
|
||||||
///
|
|
||||||
/// Retries when a concurrent request claims the same user/slot/symbol first.
|
|
||||||
Future<Map<String, dynamic>?> _createProspectiveQuestionWithAssignment(
|
|
||||||
String firebaseUid, {
|
String firebaseUid, {
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
}) async {
|
}) async {
|
||||||
for (var attempt = 0; attempt < 8; attempt++) {
|
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
|
||||||
final Map<String, dynamic>? picked =
|
connection: _questionsDb.connection,
|
||||||
await _questionsDb.pickProspectiveQuestionForUser(
|
|
||||||
firebaseUid,
|
|
||||||
now: now,
|
|
||||||
);
|
);
|
||||||
if (picked == null) {
|
|
||||||
return null;
|
for (var attempt = 0; attempt < 8; attempt++) {
|
||||||
|
final ProspectiveSlotBatch? batch =
|
||||||
|
await selection.resolveUnassignedBatch(firebaseUid, now: now);
|
||||||
|
if (batch == null || batch.unassignedAssets.isEmpty) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final DateTime olderSlotStart = DateTime.parse(
|
final DateTime baseOrder = DateTime.now().toUtc();
|
||||||
picked['olderSlotStart']! as String,
|
var createdAny = false;
|
||||||
);
|
|
||||||
final DateTime newerSlotStart = DateTime.parse(
|
|
||||||
picked['newerSlotStart']! as String,
|
|
||||||
);
|
|
||||||
final String symbol = picked['symbol']! as String;
|
|
||||||
|
|
||||||
|
for (var index = 0; index < batch.unassignedAssets.length; index++) {
|
||||||
|
final QuestionAuditAsset asset = batch.unassignedAssets[index];
|
||||||
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
olderSlotStart: olderSlotStart,
|
olderSlotStart: batch.olderSlotStart,
|
||||||
newerSlotStart: newerSlotStart,
|
newerSlotStart: batch.newerSlotStart,
|
||||||
symbol: symbol,
|
symbol: asset.symbol,
|
||||||
)) {
|
)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> picked = await selection.buildPickForAsset(
|
||||||
|
asset: asset,
|
||||||
|
olderSlotStart: batch.olderSlotStart,
|
||||||
|
newerSlotStart: batch.newerSlotStart,
|
||||||
|
);
|
||||||
|
|
||||||
final Map<String, dynamic> question = await _questionsDb.createQuestion(
|
final Map<String, dynamic> question = await _questionsDb.createQuestion(
|
||||||
assignedUserId: firebaseUid,
|
assignedUserId: firebaseUid,
|
||||||
questionText: picked['questionText']! as String,
|
questionText: picked['questionText']! as String,
|
||||||
@ -120,7 +99,7 @@ class QuestionService {
|
|||||||
pipelineStep: 'guess_weekly_move:await_answer',
|
pipelineStep: 'guess_weekly_move:await_answer',
|
||||||
metadata: <String, dynamic>{
|
metadata: <String, dynamic>{
|
||||||
'prospective_question_id': picked['id'],
|
'prospective_question_id': picked['id'],
|
||||||
'symbol': symbol,
|
'symbol': asset.symbol,
|
||||||
'older_slot_start': picked['olderSlotStart'],
|
'older_slot_start': picked['olderSlotStart'],
|
||||||
'newer_slot_start': picked['newerSlotStart'],
|
'newer_slot_start': picked['newerSlotStart'],
|
||||||
'price_delta_pct': picked['priceDeltaPct'],
|
'price_delta_pct': picked['priceDeltaPct'],
|
||||||
@ -129,11 +108,12 @@ class QuestionService {
|
|||||||
|
|
||||||
final bool assigned = await _assignmentsDb.insertPendingIfAbsent(
|
final bool assigned = await _assignmentsDb.insertPendingIfAbsent(
|
||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
olderSlotStart: olderSlotStart,
|
olderSlotStart: batch.olderSlotStart,
|
||||||
newerSlotStart: newerSlotStart,
|
newerSlotStart: batch.newerSlotStart,
|
||||||
symbol: symbol,
|
symbol: asset.symbol,
|
||||||
prospectiveQuestionId: picked['id']! as String,
|
prospectiveQuestionId: picked['id']! as String,
|
||||||
questionId: question['id']! as String,
|
questionId: question['id']! as String,
|
||||||
|
viewOrderAt: baseOrder.add(Duration(milliseconds: index)),
|
||||||
);
|
);
|
||||||
if (!assigned) {
|
if (!assigned) {
|
||||||
await _questionsDb.deleteUnansweredQuestion(
|
await _questionsDb.deleteUnansweredQuestion(
|
||||||
@ -142,13 +122,13 @@ class QuestionService {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
createdAny = true;
|
||||||
return _questionsDb.toClientPayload(
|
}
|
||||||
question,
|
|
||||||
unansweredCount: 1,
|
if (createdAny) {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts a question and pushes it to connected SignalR clients.
|
/// Inserts a question and pushes it to connected SignalR clients.
|
||||||
|
|||||||
@ -33,6 +33,22 @@ class QuestionsDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const String _questionsSelectColumns = '''
|
||||||
|
q.id, q.assigned_user_id, q.question_text, q.user_response, q.correct_answer,
|
||||||
|
q.created_at, q.modified_at, q.source_tag, q.pipeline_key, q.pipeline_step,
|
||||||
|
q.metadata''';
|
||||||
|
|
||||||
|
static const String _unansweredQuestionsJoin = '''
|
||||||
|
FROM questions q
|
||||||
|
LEFT JOIN market_history_prospective_assignments a
|
||||||
|
ON a.question_id = q.id
|
||||||
|
AND a.assigned_user_id = q.assigned_user_id
|
||||||
|
AND a.status = 'pending'
|
||||||
|
WHERE q.assigned_user_id = @uid AND q.user_response IS NULL''';
|
||||||
|
|
||||||
|
static const String _unansweredQuestionsOrder = '''
|
||||||
|
ORDER BY COALESCE(a.view_order_at, q.created_at) ASC, q.id ASC''';
|
||||||
|
|
||||||
/// Latest unanswered question for [assignedUserId], or null if none.
|
/// Latest unanswered question for [assignedUserId], or null if none.
|
||||||
Future<Map<String, dynamic>?> findUnansweredQuestion(
|
Future<Map<String, dynamic>?> findUnansweredQuestion(
|
||||||
String assignedUserId,
|
String assignedUserId,
|
||||||
@ -40,12 +56,9 @@ class QuestionsDb {
|
|||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
'''
|
'''
|
||||||
SELECT id, assigned_user_id, question_text, user_response, correct_answer,
|
SELECT $_questionsSelectColumns
|
||||||
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
$_unansweredQuestionsJoin
|
||||||
metadata
|
$_unansweredQuestionsOrder
|
||||||
FROM questions
|
|
||||||
WHERE assigned_user_id = @uid AND user_response IS NULL
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
''',
|
''',
|
||||||
),
|
),
|
||||||
@ -64,12 +77,9 @@ class QuestionsDb {
|
|||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
'''
|
'''
|
||||||
SELECT id, assigned_user_id, question_text, user_response, correct_answer,
|
SELECT $_questionsSelectColumns
|
||||||
created_at, modified_at, source_tag, pipeline_key, pipeline_step,
|
$_unansweredQuestionsJoin
|
||||||
metadata
|
$_unansweredQuestionsOrder
|
||||||
FROM questions
|
|
||||||
WHERE assigned_user_id = @uid AND user_response IS NULL
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
''',
|
''',
|
||||||
),
|
),
|
||||||
parameters: <String, dynamic>{'uid': assignedUserId},
|
parameters: <String, dynamic>{'uid': assignedUserId},
|
||||||
@ -77,6 +87,24 @@ class QuestionsDb {
|
|||||||
return result.map(_rowFromResult).toList();
|
return result.map(_rowFromResult).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FIFO head after deferring [deferredQuestionId] (falls back to sole item).
|
||||||
|
Future<Map<String, dynamic>?> findNextUnansweredAfterDefer({
|
||||||
|
required String assignedUserId,
|
||||||
|
required String deferredQuestionId,
|
||||||
|
}) async {
|
||||||
|
final List<Map<String, dynamic>> rows =
|
||||||
|
await listUnansweredQuestions(assignedUserId);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final Map<String, dynamic> row in rows) {
|
||||||
|
if (row['id'] != deferredQuestionId) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.first;
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes an unanswered question owned by [assignedUserId].
|
/// Removes an unanswered question owned by [assignedUserId].
|
||||||
Future<void> deleteUnansweredQuestion({
|
Future<void> deleteUnansweredQuestion({
|
||||||
required String questionId,
|
required String questionId,
|
||||||
@ -243,17 +271,27 @@ class QuestionsDb {
|
|||||||
required String assignedUserId,
|
required String assignedUserId,
|
||||||
}) async {
|
}) async {
|
||||||
final DateTime now = DateTime.now().toUtc();
|
final DateTime now = DateTime.now().toUtc();
|
||||||
|
final ProspectiveGuessAssignmentsDb assignmentsDb =
|
||||||
|
ProspectiveGuessAssignmentsDb(_connection);
|
||||||
|
final bool pushed = await assignmentsDb.pushToBackOfQueue(
|
||||||
|
questionId: questionId,
|
||||||
|
firebaseUid: assignedUserId,
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pushed) {
|
||||||
|
return _deferQuestionWithoutAssignment(
|
||||||
|
questionId: questionId,
|
||||||
|
assignedUserId: assignedUserId,
|
||||||
|
modifiedAt: now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
'''
|
'''
|
||||||
UPDATE questions q
|
UPDATE questions q
|
||||||
SET created_at = sub.max_ts + INTERVAL '1 millisecond',
|
SET modified_at = @modified_at
|
||||||
modified_at = @modified_at
|
|
||||||
FROM (
|
|
||||||
SELECT COALESCE(MAX(created_at), @modified_at) AS max_ts
|
|
||||||
FROM questions
|
|
||||||
WHERE assigned_user_id = @uid AND user_response IS NULL
|
|
||||||
) sub
|
|
||||||
WHERE q.id = @id::uuid
|
WHERE q.id = @id::uuid
|
||||||
AND q.assigned_user_id = @uid
|
AND q.assigned_user_id = @uid
|
||||||
AND q.user_response IS NULL
|
AND q.user_response IS NULL
|
||||||
@ -274,6 +312,45 @@ class QuestionsDb {
|
|||||||
return _rowFromResult(result.first);
|
return _rowFromResult(result.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy defer for questions without a pending assignment row.
|
||||||
|
Future<Map<String, dynamic>?> _deferQuestionWithoutAssignment({
|
||||||
|
required String questionId,
|
||||||
|
required String assignedUserId,
|
||||||
|
required DateTime modifiedAt,
|
||||||
|
}) async {
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE questions q
|
||||||
|
SET created_at = sub.max_ts + INTERVAL '1 millisecond',
|
||||||
|
modified_at = @modified_at
|
||||||
|
FROM (
|
||||||
|
SELECT COALESCE(MAX(q.created_at), @modified_at) AS max_ts
|
||||||
|
FROM questions q
|
||||||
|
WHERE q.assigned_user_id = @uid
|
||||||
|
AND q.user_response IS NULL
|
||||||
|
AND q.id <> @id::uuid
|
||||||
|
) sub
|
||||||
|
WHERE q.id = @id::uuid
|
||||||
|
AND q.assigned_user_id = @uid
|
||||||
|
AND q.user_response IS NULL
|
||||||
|
RETURNING q.id, q.assigned_user_id, q.question_text, q.user_response,
|
||||||
|
q.correct_answer, q.created_at, q.modified_at,
|
||||||
|
q.source_tag, q.pipeline_key, q.pipeline_step, q.metadata
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'id': questionId,
|
||||||
|
'uid': assignedUserId,
|
||||||
|
'modified_at': modifiedAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _rowFromResult(result.first);
|
||||||
|
}
|
||||||
|
|
||||||
/// Count of unanswered questions assigned to [assignedUserId].
|
/// Count of unanswered questions assigned to [assignedUserId].
|
||||||
Future<int> countUnansweredQuestions(String assignedUserId) async {
|
Future<int> countUnansweredQuestions(String assignedUserId) async {
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
WHERE a.assigned_user_id = @uid
|
WHERE a.assigned_user_id = @uid
|
||||||
AND a.status = @pending
|
AND a.status = @pending
|
||||||
AND q.user_response IS NULL
|
AND q.user_response IS NULL
|
||||||
ORDER BY a.created_at ASC
|
ORDER BY a.view_order_at ASC, a.question_id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
''',
|
''',
|
||||||
),
|
),
|
||||||
@ -94,6 +94,48 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
return result.isNotEmpty;
|
return result.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves a pending assignment to the tail of [firebaseUid]'s FIFO queue.
|
||||||
|
///
|
||||||
|
/// Sets [view_order_at] to the current time (or later than any pending peer)
|
||||||
|
/// so the deferred question is served last on the next fetch.
|
||||||
|
Future<bool> pushToBackOfQueue({
|
||||||
|
required String questionId,
|
||||||
|
required String firebaseUid,
|
||||||
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
final DateTime tick = (now ?? DateTime.now()).toUtc();
|
||||||
|
final Result result = await _connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE market_history_prospective_assignments a
|
||||||
|
SET view_order_at = (
|
||||||
|
SELECT GREATEST(
|
||||||
|
@now,
|
||||||
|
COALESCE(MAX(a2.view_order_at), @now) + INTERVAL '1 millisecond'
|
||||||
|
)
|
||||||
|
FROM market_history_prospective_assignments a2
|
||||||
|
INNER JOIN questions q2 ON q2.id = a2.question_id
|
||||||
|
WHERE a2.assigned_user_id = @uid
|
||||||
|
AND a2.status = @pending
|
||||||
|
AND q2.user_response IS NULL
|
||||||
|
AND a2.question_id <> @question_id::uuid
|
||||||
|
)
|
||||||
|
WHERE a.question_id = @question_id::uuid
|
||||||
|
AND a.assigned_user_id = @uid
|
||||||
|
AND a.status = @pending
|
||||||
|
RETURNING a.id
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'question_id': questionId,
|
||||||
|
'uid': firebaseUid,
|
||||||
|
'pending': statusPending,
|
||||||
|
'now': tick,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Set<String>> assignedSymbolsForSlotPair({
|
Future<Set<String>> assignedSymbolsForSlotPair({
|
||||||
required String firebaseUid,
|
required String firebaseUid,
|
||||||
required DateTime olderSlotStart,
|
required DateTime olderSlotStart,
|
||||||
@ -154,6 +196,7 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
required String symbol,
|
required String symbol,
|
||||||
required String prospectiveQuestionId,
|
required String prospectiveQuestionId,
|
||||||
required String questionId,
|
required String questionId,
|
||||||
|
DateTime? viewOrderAt,
|
||||||
}) async {
|
}) async {
|
||||||
final Result result = await _connection.execute(
|
final Result result = await _connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -165,7 +208,8 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
symbol,
|
symbol,
|
||||||
prospective_question_id,
|
prospective_question_id,
|
||||||
question_id,
|
question_id,
|
||||||
status
|
status,
|
||||||
|
view_order_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@uid,
|
@uid,
|
||||||
@older,
|
@older,
|
||||||
@ -173,7 +217,8 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
@symbol,
|
@symbol,
|
||||||
@prospective_question_id::uuid,
|
@prospective_question_id::uuid,
|
||||||
@question_id::uuid,
|
@question_id::uuid,
|
||||||
@pending
|
@pending,
|
||||||
|
COALESCE(@view_order_at, now())
|
||||||
)
|
)
|
||||||
ON CONFLICT (assigned_user_id, older_slot_start, newer_slot_start, symbol)
|
ON CONFLICT (assigned_user_id, older_slot_start, newer_slot_start, symbol)
|
||||||
DO NOTHING
|
DO NOTHING
|
||||||
@ -188,6 +233,7 @@ class ProspectiveGuessAssignmentsDb {
|
|||||||
'prospective_question_id': prospectiveQuestionId,
|
'prospective_question_id': prospectiveQuestionId,
|
||||||
'question_id': questionId,
|
'question_id': questionId,
|
||||||
'pending': statusPending,
|
'pending': statusPending,
|
||||||
|
'view_order_at': viewOrderAt?.toUtc(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return result.isNotEmpty;
|
return result.isNotEmpty;
|
||||||
|
|||||||
@ -8,12 +8,24 @@ import 'market_history_session_slot.dart';
|
|||||||
import 'prospective_guess_assignments_db.dart';
|
import 'prospective_guess_assignments_db.dart';
|
||||||
import 'user_trading_state_db.dart';
|
import 'user_trading_state_db.dart';
|
||||||
|
|
||||||
|
/// Unassigned top-half symbols for the user's active session-half slot pair.
|
||||||
|
class ProspectiveSlotBatch {
|
||||||
|
const ProspectiveSlotBatch({
|
||||||
|
required this.olderSlotStart,
|
||||||
|
required this.newerSlotStart,
|
||||||
|
required this.unassignedAssets,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime olderSlotStart;
|
||||||
|
final DateTime newerSlotStart;
|
||||||
|
final List<QuestionAuditAsset> unassignedAssets;
|
||||||
|
}
|
||||||
|
|
||||||
/// Picks prospective guess questions by session-half slot progression.
|
/// Picks prospective guess questions by session-half slot progression.
|
||||||
///
|
///
|
||||||
/// User state stores the older slot edge in `guess_score.slot_start`. Each slot
|
/// User state stores the older slot edge in `guess_score.slot_start`. Each slot
|
||||||
/// pair gets one question per top-half volume asset
|
/// pair gets the full top-half volume batch at once (one question per symbol in
|
||||||
/// ([market_history_prospective_assignments] enforces uniqueness per symbol).
|
/// that batch). [slot_start] advances after every top-half symbol is answered.
|
||||||
/// [slot_start] advances after every top-half symbol in the pair is answered.
|
|
||||||
class ProspectiveGuessSelection {
|
class ProspectiveGuessSelection {
|
||||||
ProspectiveGuessSelection({
|
ProspectiveGuessSelection({
|
||||||
required Connection connection,
|
required Connection connection,
|
||||||
@ -41,13 +53,33 @@ class ProspectiveGuessSelection {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Next symbol for [firebaseUid] when no assignment exists for the pair.
|
/// Next symbol for [firebaseUid] when batching one question at a time.
|
||||||
///
|
///
|
||||||
/// Returns null when caught up, when a pending assignment already exists, or
|
/// Returns null when caught up or when the current slot pair already has
|
||||||
/// when every top-half symbol in the current pair has been answered.
|
/// pending assignments.
|
||||||
Future<Map<String, dynamic>?> pickForUser(
|
Future<Map<String, dynamic>?> pickForUser(
|
||||||
String firebaseUid, {
|
String firebaseUid, {
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
final ProspectiveSlotBatch? batch =
|
||||||
|
await resolveUnassignedBatch(firebaseUid, now: now);
|
||||||
|
if (batch == null || batch.unassignedAssets.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildPickForAsset(
|
||||||
|
asset: batch.unassignedAssets.first,
|
||||||
|
olderSlotStart: batch.olderSlotStart,
|
||||||
|
newerSlotStart: batch.newerSlotStart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All top-half symbols in the active slot pair that still need a question.
|
||||||
|
///
|
||||||
|
/// Advances [slot_start] past completed pairs. Returns null when caught up or
|
||||||
|
/// when every top-half symbol in the active pair is already assigned.
|
||||||
|
Future<ProspectiveSlotBatch?> resolveUnassignedBatch(
|
||||||
|
String firebaseUid, {
|
||||||
|
DateTime? now,
|
||||||
}) async {
|
}) async {
|
||||||
final DateTime tick = (now ?? DateTime.now()).toUtc();
|
final DateTime tick = (now ?? DateTime.now()).toUtc();
|
||||||
final DateTime lastCompleted =
|
final DateTime lastCompleted =
|
||||||
@ -65,14 +97,6 @@ class ProspectiveGuessSelection {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await _assignmentsDb.hasPendingAssignmentForSlotPair(
|
|
||||||
firebaseUid: firebaseUid,
|
|
||||||
olderSlotStart: olderSlot,
|
|
||||||
newerSlotStart: newerSlot,
|
|
||||||
)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<QuestionAuditAsset> topHalf =
|
final List<QuestionAuditAsset> topHalf =
|
||||||
await _topHalfAssetsForSlotPair(
|
await _topHalfAssetsForSlotPair(
|
||||||
olderSlotStart: olderSlot,
|
olderSlotStart: olderSlot,
|
||||||
@ -98,28 +122,24 @@ class ProspectiveGuessSelection {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair(
|
final List<QuestionAuditAsset> unassigned =
|
||||||
|
await _unassignedAssetsForSlotPair(
|
||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
olderSlotStart: olderSlot,
|
olderSlotStart: olderSlot,
|
||||||
newerSlotStart: newerSlot,
|
newerSlotStart: newerSlot,
|
||||||
topHalf: topHalf,
|
topHalf: topHalf,
|
||||||
);
|
);
|
||||||
if (asset != null) {
|
if (unassigned.isNotEmpty) {
|
||||||
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
|
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
|
||||||
final String id = await _upsertProspectiveRow(
|
return ProspectiveSlotBatch(
|
||||||
asset: asset,
|
|
||||||
olderSlotStart: olderSlot,
|
olderSlotStart: olderSlot,
|
||||||
newerSlotStart: newerSlot,
|
newerSlotStart: newerSlot,
|
||||||
|
unassignedAssets: unassigned,
|
||||||
);
|
);
|
||||||
return <String, dynamic>{
|
}
|
||||||
'id': id,
|
|
||||||
'questionText': _questionText(asset.symbol),
|
if (!topSymbols.every(answeredSymbols.contains)) {
|
||||||
'correctAnswer': asset.priceDelta,
|
return null;
|
||||||
'symbol': asset.symbol,
|
|
||||||
'olderSlotStart': olderSlot.toIso8601String(),
|
|
||||||
'newerSlotStart': newerSlot.toIso8601String(),
|
|
||||||
'priceDeltaPct': asset.priceDelta,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
olderSlot = newerSlot;
|
olderSlot = newerSlot;
|
||||||
@ -127,6 +147,52 @@ class ProspectiveGuessSelection {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
Future<Map<String, dynamic>> buildPickForAsset({
|
||||||
|
required QuestionAuditAsset asset,
|
||||||
|
required DateTime olderSlotStart,
|
||||||
|
required DateTime newerSlotStart,
|
||||||
|
}) async {
|
||||||
|
final DateTime older =
|
||||||
|
MarketHistorySessionSlot.slotStartContaining(olderSlotStart.toUtc());
|
||||||
|
final DateTime newer =
|
||||||
|
MarketHistorySessionSlot.slotStartContaining(newerSlotStart.toUtc());
|
||||||
|
final String id = await _upsertProspectiveRow(
|
||||||
|
asset: asset,
|
||||||
|
olderSlotStart: older,
|
||||||
|
newerSlotStart: newer,
|
||||||
|
);
|
||||||
|
return <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
'questionText': _questionText(asset.symbol),
|
||||||
|
'correctAnswer': asset.priceDelta,
|
||||||
|
'symbol': asset.symbol,
|
||||||
|
'olderSlotStart': older.toIso8601String(),
|
||||||
|
'newerSlotStart': newer.toIso8601String(),
|
||||||
|
'priceDeltaPct': asset.priceDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<QuestionAuditAsset>> _unassignedAssetsForSlotPair({
|
||||||
|
required String firebaseUid,
|
||||||
|
required DateTime olderSlotStart,
|
||||||
|
required DateTime newerSlotStart,
|
||||||
|
required List<QuestionAuditAsset> topHalf,
|
||||||
|
}) async {
|
||||||
|
final List<QuestionAuditAsset> unassigned = <QuestionAuditAsset>[];
|
||||||
|
for (final QuestionAuditAsset asset in topHalf) {
|
||||||
|
final bool alreadyAssigned =
|
||||||
|
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
||||||
|
firebaseUid: firebaseUid,
|
||||||
|
olderSlotStart: olderSlotStart,
|
||||||
|
newerSlotStart: newerSlotStart,
|
||||||
|
symbol: asset.symbol,
|
||||||
|
);
|
||||||
|
if (!alreadyAssigned) {
|
||||||
|
unassigned.add(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unassigned;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
|
Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
|
||||||
required DateTime olderSlotStart,
|
required DateTime olderSlotStart,
|
||||||
@ -140,28 +206,6 @@ class ProspectiveGuessSelection {
|
|||||||
return questionAuditTopHalfVolumeAssets(assets);
|
return questionAuditTopHalfVolumeAssets(assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highest-volume symbol in [topHalf] the user has not yet been assigned.
|
|
||||||
Future<QuestionAuditAsset?> _pickNextAssetForSlotPair({
|
|
||||||
required String firebaseUid,
|
|
||||||
required DateTime olderSlotStart,
|
|
||||||
required DateTime newerSlotStart,
|
|
||||||
required List<QuestionAuditAsset> topHalf,
|
|
||||||
}) async {
|
|
||||||
for (final QuestionAuditAsset asset in topHalf) {
|
|
||||||
final bool alreadyAssigned =
|
|
||||||
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
|
|
||||||
firebaseUid: firebaseUid,
|
|
||||||
olderSlotStart: olderSlotStart,
|
|
||||||
newerSlotStart: newerSlotStart,
|
|
||||||
symbol: asset.symbol,
|
|
||||||
);
|
|
||||||
if (!alreadyAssigned) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _upsertProspectiveRow({
|
Future<String> _upsertProspectiveRow({
|
||||||
required QuestionAuditAsset asset,
|
required QuestionAuditAsset asset,
|
||||||
required DateTime olderSlotStart,
|
required DateTime olderSlotStart,
|
||||||
|
|||||||
16
server/migrations/017_prospective_assignments_view_order.sql
Normal file
16
server/migrations/017_prospective_assignments_view_order.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- Per-user FIFO queue order for pending guess assignments (defer pushes to back).
|
||||||
|
|
||||||
|
ALTER TABLE market_history_prospective_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS view_order_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
UPDATE market_history_prospective_assignments
|
||||||
|
SET view_order_at = created_at
|
||||||
|
WHERE view_order_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE market_history_prospective_assignments
|
||||||
|
ALTER COLUMN view_order_at SET NOT NULL,
|
||||||
|
ALTER COLUMN view_order_at SET DEFAULT now();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS market_history_prospective_assignments_user_view_order_idx
|
||||||
|
ON market_history_prospective_assignments (assigned_user_id, view_order_at ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
@ -11,7 +11,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart';
|
|||||||
import 'package:dotenv/dotenv.dart';
|
import 'package:dotenv/dotenv.dart';
|
||||||
import 'package:postgres/postgres.dart';
|
import 'package:postgres/postgres.dart';
|
||||||
|
|
||||||
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–016.
|
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–017.
|
||||||
class TestDb {
|
class TestDb {
|
||||||
TestDb._(this.db, this._connection, this.databaseUrl);
|
TestDb._(this.db, this._connection, this.databaseUrl);
|
||||||
|
|
||||||
|
|||||||
@ -576,6 +576,7 @@ void main() {
|
|||||||
final Map<String, dynamic>? firstPayload =
|
final Map<String, dynamic>? firstPayload =
|
||||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||||
expect(firstPayload, isNotNull);
|
expect(firstPayload, isNotNull);
|
||||||
|
expect(firstPayload!['unansweredCount'], 2);
|
||||||
|
|
||||||
final Result assignmentRows = await testDb!.connection.execute(
|
final Result assignmentRows = await testDb!.connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -588,9 +589,10 @@ void main() {
|
|||||||
),
|
),
|
||||||
parameters: <String, dynamic>{'uid': uid},
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
);
|
);
|
||||||
expect(assignmentRows, hasLength(1));
|
expect(assignmentRows, hasLength(2));
|
||||||
expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending);
|
expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending);
|
||||||
expect(assignmentRows.first[0], 'HIGH');
|
expect(assignmentRows.first[0], 'HIGH');
|
||||||
|
expect(assignmentRows.elementAt(1)[0], 'MID');
|
||||||
expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot);
|
expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot);
|
||||||
expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot);
|
expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot);
|
||||||
|
|
||||||
@ -599,21 +601,23 @@ void main() {
|
|||||||
final Map<String, dynamic>? reloginPayload =
|
final Map<String, dynamic>? reloginPayload =
|
||||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||||
expect(reloginPayload, isNotNull);
|
expect(reloginPayload, isNotNull);
|
||||||
expect(reloginPayload!['id'], firstPayload!['id']);
|
expect(reloginPayload!['id'], firstPayload['id']);
|
||||||
|
expect(reloginPayload['unansweredCount'], 2);
|
||||||
|
|
||||||
final List<Map<String, dynamic>> queued =
|
final List<Map<String, dynamic>> queued =
|
||||||
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||||
expect(queued, hasLength(1));
|
expect(queued, hasLength(2));
|
||||||
await testDb!.questionsDb.submitAnswer(
|
await testDb!.questionsDb.submitAnswer(
|
||||||
questionId: queued.single['id']! as String,
|
questionId: queued.first['id']! as String,
|
||||||
assignedUserId: uid,
|
assignedUserId: uid,
|
||||||
userResponse: 0,
|
userResponse: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
final Map<String, dynamic>? secondPayload =
|
final Map<String, dynamic>? afterFirstAnswer =
|
||||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||||
expect(secondPayload, isNotNull);
|
expect(afterFirstAnswer, isNotNull);
|
||||||
expect(secondPayload!['id'], isNot(firstPayload['id']));
|
expect(afterFirstAnswer!['unansweredCount'], 1);
|
||||||
|
expect(afterFirstAnswer['id'], isNot(firstPayload['id']));
|
||||||
|
|
||||||
final Result secondPairAssignments = await testDb!.connection.execute(
|
final Result secondPairAssignments = await testDb!.connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -629,13 +633,9 @@ void main() {
|
|||||||
expect(secondPairAssignments, hasLength(2));
|
expect(secondPairAssignments, hasLength(2));
|
||||||
expect(secondPairAssignments.first[0], 'HIGH');
|
expect(secondPairAssignments.first[0], 'HIGH');
|
||||||
expect(secondPairAssignments.last[0], 'MID');
|
expect(secondPairAssignments.last[0], 'MID');
|
||||||
expect(secondPairAssignments.last[1],
|
|
||||||
ProspectiveGuessAssignmentsDb.statusPending);
|
|
||||||
expect((secondPairAssignments.last[2]! as DateTime).toUtc(), olderSlot);
|
|
||||||
expect((secondPairAssignments.last[3]! as DateTime).toUtc(), newerSlot);
|
|
||||||
|
|
||||||
await testDb!.questionsDb.submitAnswer(
|
await testDb!.questionsDb.submitAnswer(
|
||||||
questionId: secondPayload['id']! as String,
|
questionId: afterFirstAnswer['id']! as String,
|
||||||
assignedUserId: uid,
|
assignedUserId: uid,
|
||||||
userResponse: 0,
|
userResponse: 0,
|
||||||
);
|
);
|
||||||
@ -643,8 +643,8 @@ void main() {
|
|||||||
final Map<String, dynamic>? thirdPayload =
|
final Map<String, dynamic>? thirdPayload =
|
||||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||||
expect(thirdPayload, isNotNull);
|
expect(thirdPayload, isNotNull);
|
||||||
expect(thirdPayload!['id'], isNot(firstPayload['id']));
|
expect(thirdPayload!['unansweredCount'], 1);
|
||||||
expect(thirdPayload['id'], isNot(secondPayload['id']));
|
expect(thirdPayload['id'], isNot(firstPayload['id']));
|
||||||
|
|
||||||
final Result allAssignments = await testDb!.connection.execute(
|
final Result allAssignments = await testDb!.connection.execute(
|
||||||
Sql.named(
|
Sql.named(
|
||||||
@ -658,9 +658,7 @@ void main() {
|
|||||||
parameters: <String, dynamic>{'uid': uid},
|
parameters: <String, dynamic>{'uid': uid},
|
||||||
);
|
);
|
||||||
expect(allAssignments, hasLength(3));
|
expect(allAssignments, hasLength(3));
|
||||||
expect(allAssignments.last[0], 'HIGH');
|
expect(allAssignments.elementAt(2)[0], 'HIGH');
|
||||||
expect((allAssignments.last[1]! as DateTime).toUtc(), newerSlot);
|
|
||||||
expect((allAssignments.last[2]! as DateTime).toUtc(), nextOlderSlot);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('blocks duplicate assignment for same user symbol and slot pair', () async {
|
test('blocks duplicate assignment for same user symbol and slot pair', () async {
|
||||||
@ -875,4 +873,291 @@ void main() {
|
|||||||
expect(metadata['newer_slot_start'], newerSlot.toIso8601String());
|
expect(metadata['newer_slot_start'], newerSlot.toIso8601String());
|
||||||
expect(created['text'], contains('BOOT'));
|
expect(created['text'], contains('BOOT'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deferQuestion pushes assignment view_order to back of FIFO queue',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'fifo-defer-uid';
|
||||||
|
final DateTime now = DateTime.utc(2026, 5, 10, 16);
|
||||||
|
final DateTime newerSlot =
|
||||||
|
MarketHistorySessionSlot.lastCompletedSlotStart(now);
|
||||||
|
final DateTime olderSlot =
|
||||||
|
MarketHistorySessionSlot.previousSlotStart(newerSlot)!;
|
||||||
|
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
Future<String> insertProspective(String symbol) async {
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO market_history_prospective_questions (
|
||||||
|
compare_until, newer_slot_start, older_slot_start,
|
||||||
|
symbol, question_text, correct_answer,
|
||||||
|
price_delta_pct, volume_delta_pct, avg_volume_usd,
|
||||||
|
older_slot, newer_slot
|
||||||
|
) VALUES (
|
||||||
|
@compare_until, @newer, @older,
|
||||||
|
@symbol, @text, 1.0,
|
||||||
|
1.0, 0.0, 1000,
|
||||||
|
'{}'::jsonb, '{}'::jsonb
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
|
||||||
|
'newer': newerSlot,
|
||||||
|
'older': olderSlot,
|
||||||
|
'symbol': symbol,
|
||||||
|
'text': 'Move for $symbol?',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return rows.first[0].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final String prospectiveHigh = await insertProspective('HIGH');
|
||||||
|
final String prospectiveMid = await insertProspective('MID');
|
||||||
|
|
||||||
|
final Map<String, dynamic> firstQuestion =
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Guess HIGH',
|
||||||
|
correctAnswer: 1,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
metadata: <String, dynamic>{'prospective_question_id': prospectiveHigh},
|
||||||
|
);
|
||||||
|
final Map<String, dynamic> secondQuestion =
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Guess MID',
|
||||||
|
correctAnswer: 1,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
metadata: <String, dynamic>{'prospective_question_id': prospectiveMid},
|
||||||
|
);
|
||||||
|
|
||||||
|
final ProspectiveGuessAssignmentsDb assignmentsDb =
|
||||||
|
ProspectiveGuessAssignmentsDb(testDb!.connection);
|
||||||
|
await assignmentsDb.insertPending(
|
||||||
|
firebaseUid: uid,
|
||||||
|
olderSlotStart: olderSlot,
|
||||||
|
newerSlotStart: newerSlot,
|
||||||
|
symbol: 'HIGH',
|
||||||
|
prospectiveQuestionId: prospectiveHigh,
|
||||||
|
questionId: firstQuestion['id']! as String,
|
||||||
|
);
|
||||||
|
await assignmentsDb.insertPending(
|
||||||
|
firebaseUid: uid,
|
||||||
|
olderSlotStart: olderSlot,
|
||||||
|
newerSlotStart: newerSlot,
|
||||||
|
symbol: 'MID',
|
||||||
|
prospectiveQuestionId: prospectiveMid,
|
||||||
|
questionId: secondQuestion['id']! as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
final DateTime firstOrder = DateTime.utc(2026, 5, 10, 10);
|
||||||
|
final DateTime secondOrder = DateTime.utc(2026, 5, 10, 11);
|
||||||
|
await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE market_history_prospective_assignments
|
||||||
|
SET view_order_at = @view_order_at
|
||||||
|
WHERE question_id = @question_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'view_order_at': firstOrder,
|
||||||
|
'question_id': firstQuestion['id']! as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE market_history_prospective_assignments
|
||||||
|
SET view_order_at = @view_order_at
|
||||||
|
WHERE question_id = @question_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'view_order_at': secondOrder,
|
||||||
|
'question_id': secondQuestion['id']! as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? headBefore =
|
||||||
|
await testDb!.questionsDb.findUnansweredQuestion(uid);
|
||||||
|
expect(headBefore!['id'], firstQuestion['id']);
|
||||||
|
|
||||||
|
final DateTime deferStarted = DateTime.now().toUtc();
|
||||||
|
final Map<String, dynamic>? deferred = await testDb!.questionsDb.deferQuestion(
|
||||||
|
questionId: firstQuestion['id']! as String,
|
||||||
|
assignedUserId: uid,
|
||||||
|
);
|
||||||
|
expect(deferred, isNotNull);
|
||||||
|
expect(deferred!['createdAt'], firstQuestion['createdAt']);
|
||||||
|
|
||||||
|
final Result deferredOrder = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
SELECT view_order_at
|
||||||
|
FROM market_history_prospective_assignments
|
||||||
|
WHERE question_id = @question_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'question_id': firstQuestion['id']! as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final DateTime deferredViewOrder =
|
||||||
|
(deferredOrder.first[0]! as DateTime).toUtc();
|
||||||
|
expect(
|
||||||
|
deferredViewOrder.isAfter(secondOrder) ||
|
||||||
|
deferredViewOrder.isAtSameMomentAs(secondOrder),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(deferredViewOrder.isBefore(deferStarted), isFalse);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? headAfter =
|
||||||
|
await testDb!.questionsDb.findUnansweredQuestion(uid);
|
||||||
|
expect(headAfter!['id'], secondQuestion['id']);
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> queue =
|
||||||
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||||
|
expect(queue, hasLength(2));
|
||||||
|
expect(queue.first['id'], secondQuestion['id']);
|
||||||
|
expect(queue.last['id'], firstQuestion['id']);
|
||||||
|
|
||||||
|
final String? pendingHead =
|
||||||
|
await assignmentsDb.findPendingQuestionId(uid);
|
||||||
|
expect(pendingHead, secondQuestion['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'deferQuestion moves head to back when it had the latest view_order_at',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped(
|
||||||
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'fifo-defer-head-latest-uid';
|
||||||
|
final DateTime olderSlot = DateTime.utc(2026, 5, 10, 10);
|
||||||
|
final DateTime newerSlot = DateTime.utc(2026, 5, 10, 14);
|
||||||
|
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
|
||||||
|
Future<({String prospectiveId, Map<String, dynamic> question})>
|
||||||
|
seedAssigned(
|
||||||
|
String symbol,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final Result rows = await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
INSERT INTO market_history_prospective_questions (
|
||||||
|
compare_until, newer_slot_start, older_slot_start,
|
||||||
|
symbol, question_text, correct_answer,
|
||||||
|
price_delta_pct, volume_delta_pct, avg_volume_usd,
|
||||||
|
older_slot, newer_slot
|
||||||
|
) VALUES (
|
||||||
|
@compare_until, @newer, @older,
|
||||||
|
@symbol, @text, 1.0,
|
||||||
|
1.0, 0.0, 1000,
|
||||||
|
'{}'::jsonb, '{}'::jsonb
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
|
||||||
|
'newer': newerSlot,
|
||||||
|
'older': olderSlot,
|
||||||
|
'symbol': symbol,
|
||||||
|
'text': text,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final String prospectiveId = rows.first[0].toString();
|
||||||
|
final Map<String, dynamic> question =
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: text,
|
||||||
|
correctAnswer: 1,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
metadata: <String, dynamic>{
|
||||||
|
'prospective_question_id': prospectiveId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ProspectiveGuessAssignmentsDb(testDb!.connection).insertPending(
|
||||||
|
firebaseUid: uid,
|
||||||
|
olderSlotStart: olderSlot,
|
||||||
|
newerSlotStart: newerSlot,
|
||||||
|
symbol: symbol,
|
||||||
|
prospectiveQuestionId: prospectiveId,
|
||||||
|
questionId: question['id']! as String,
|
||||||
|
);
|
||||||
|
return (prospectiveId: prospectiveId, question: question);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ({String prospectiveId, Map<String, dynamic> question}) first =
|
||||||
|
await seedAssigned('HEAD', 'Head question');
|
||||||
|
final ({String prospectiveId, Map<String, dynamic> question}) second =
|
||||||
|
await seedAssigned('TAIL', 'Tail question');
|
||||||
|
|
||||||
|
final DateTime tailOrder = DateTime.utc(2026, 5, 10, 9);
|
||||||
|
final DateTime headOrder = DateTime.utc(2026, 5, 10, 12);
|
||||||
|
await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE market_history_prospective_assignments
|
||||||
|
SET view_order_at = @tail_order
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND question_id = @tail_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': uid,
|
||||||
|
'tail_id': second.question['id']! as String,
|
||||||
|
'tail_order': tailOrder,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await testDb!.connection.execute(
|
||||||
|
Sql.named(
|
||||||
|
'''
|
||||||
|
UPDATE market_history_prospective_assignments
|
||||||
|
SET view_order_at = @head_order
|
||||||
|
WHERE assigned_user_id = @uid
|
||||||
|
AND question_id = @head_id::uuid
|
||||||
|
''',
|
||||||
|
),
|
||||||
|
parameters: <String, dynamic>{
|
||||||
|
'uid': uid,
|
||||||
|
'head_id': first.question['id']! as String,
|
||||||
|
'head_order': headOrder,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? headBefore =
|
||||||
|
await testDb!.questionsDb.findUnansweredQuestion(uid);
|
||||||
|
expect(headBefore!['id'], second.question['id']);
|
||||||
|
|
||||||
|
await testDb!.questionsDb.deferQuestion(
|
||||||
|
questionId: second.question['id']! as String,
|
||||||
|
assignedUserId: uid,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic>? headAfter =
|
||||||
|
await testDb!.questionsDb.findUnansweredQuestion(uid);
|
||||||
|
expect(headAfter!['id'], first.question['id']);
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> queue =
|
||||||
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||||
|
expect(queue.first['id'], first.question['id']);
|
||||||
|
expect(queue.last['id'], second.question['id']);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user