342 lines
9.6 KiB
Dart
342 lines
9.6 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../../../app/theme/mapflow_theme.dart';
|
|
import '../data/voice_experiences_repository.dart';
|
|
import '../domain/place_models.dart';
|
|
|
|
class AdminVoiceExperiencesScreen extends StatefulWidget {
|
|
const AdminVoiceExperiencesScreen({super.key});
|
|
|
|
@override
|
|
State<AdminVoiceExperiencesScreen> createState() =>
|
|
_AdminVoiceExperiencesScreenState();
|
|
}
|
|
|
|
class _AdminVoiceExperiencesScreenState
|
|
extends State<AdminVoiceExperiencesScreen> {
|
|
late final Future<List<VoiceExperienceDebug>> _future = context
|
|
.read<VoiceExperiencesRepository>()
|
|
.fetchVoiceExperiences();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.mapflowTokens;
|
|
|
|
return Scaffold(
|
|
backgroundColor: tokens.mapPanel,
|
|
appBar: AppBar(title: const Text('Отзывы')),
|
|
body: FutureBuilder<List<VoiceExperienceDebug>>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError) {
|
|
return Center(child: Text(snapshot.error.toString()));
|
|
}
|
|
|
|
final reviews = snapshot.data ?? const <VoiceExperienceDebug>[];
|
|
return ListView.separated(
|
|
padding: const EdgeInsets.all(12),
|
|
itemCount: reviews.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
|
itemBuilder: (context, index) {
|
|
return _AdminVoiceExperienceRow(review: reviews[index]);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AdminVoiceExperienceRow extends StatelessWidget {
|
|
const _AdminVoiceExperienceRow({required this.review});
|
|
|
|
final VoiceExperienceDebug review;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final selectedTags = _selectedAdminTags(review.analysis);
|
|
final tokens = context.mapflowTokens;
|
|
|
|
return Material(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
review.placeName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w900),
|
|
),
|
|
),
|
|
Text(
|
|
review.status,
|
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'${review.userName} · ${review.durationSeconds}s',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.outline),
|
|
),
|
|
if (review.transcript?.trim().isNotEmpty == true) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
review.transcript!,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
const SizedBox(height: 10),
|
|
_AdminOntologySnowflake(selectedTags: selectedTags),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AdminOntologySnowflake extends StatelessWidget {
|
|
const _AdminOntologySnowflake({required this.selectedTags});
|
|
|
|
final Set<String> selectedTags;
|
|
|
|
static const _axes = [
|
|
_AdminOntologyAxis(
|
|
id: 'energy',
|
|
label: 'энергия',
|
|
angle: -math.pi / 2,
|
|
leaves: [
|
|
_AdminOntologyLeaf('calm', 'спокойное', -0.22),
|
|
_AdminOntologyLeaf('dynamic', 'живое', 0.22),
|
|
],
|
|
),
|
|
_AdminOntologyAxis(
|
|
id: 'privacy',
|
|
label: 'приватность',
|
|
angle: -math.pi / 2 + math.pi * 2 / 5,
|
|
leaves: [
|
|
_AdminOntologyLeaf('intimate', 'камерное', -0.2),
|
|
_AdminOntologyLeaf('open', 'открытое', 0.2),
|
|
],
|
|
),
|
|
_AdminOntologyAxis(
|
|
id: 'function',
|
|
label: 'сценарий',
|
|
angle: -math.pi / 2 + math.pi * 4 / 5,
|
|
leaves: [
|
|
_AdminOntologyLeaf('reset', 'выдохнуть', -0.25),
|
|
_AdminOntologyLeaf('impress', 'впечатлить', 0),
|
|
_AdminOntologyLeaf('transit', 'транзитное', 0.25),
|
|
],
|
|
),
|
|
_AdminOntologyAxis(
|
|
id: 'aesthetic',
|
|
label: 'образ',
|
|
angle: -math.pi / 2 + math.pi * 6 / 5,
|
|
leaves: [
|
|
_AdminOntologyLeaf('clean', 'чистое', -0.2),
|
|
_AdminOntologyLeaf('expressive', 'выразительное', 0.2),
|
|
],
|
|
),
|
|
_AdminOntologyAxis(
|
|
id: 'sociality',
|
|
label: 'социальность',
|
|
angle: -math.pi / 2 + math.pi * 8 / 5,
|
|
leaves: [
|
|
_AdminOntologyLeaf('solo', 'для себя', -0.2),
|
|
_AdminOntologyLeaf('group', 'для компании', 0.2),
|
|
],
|
|
),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.mapflowTokens;
|
|
|
|
return SizedBox(
|
|
height: 300,
|
|
width: double.infinity,
|
|
child: CustomPaint(
|
|
painter: _AdminOntologySnowflakePainter(
|
|
axes: _axes,
|
|
selectedTags: selectedTags,
|
|
tokens: tokens,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AdminOntologySnowflakePainter extends CustomPainter {
|
|
const _AdminOntologySnowflakePainter({
|
|
required this.axes,
|
|
required this.selectedTags,
|
|
required this.tokens,
|
|
});
|
|
|
|
final List<_AdminOntologyAxis> axes;
|
|
final Set<String> selectedTags;
|
|
final MapflowThemeTokens tokens;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = math.min(size.width, size.height);
|
|
final axisRadius = radius * 0.25;
|
|
final leafRadius = radius * 0.43;
|
|
|
|
final baseLine = Paint()
|
|
..color = tokens.snowflakeLine
|
|
..strokeWidth = 1.3
|
|
..style = PaintingStyle.stroke;
|
|
final selectedLine = Paint()
|
|
..color = tokens.danger
|
|
..strokeWidth = 2.2
|
|
..strokeCap = StrokeCap.round
|
|
..style = PaintingStyle.stroke;
|
|
final node = Paint()..color = tokens.snowflakeNode;
|
|
final selectedNode = Paint()..color = tokens.danger;
|
|
final centerNode = Paint()..color = tokens.snowflakeCenter;
|
|
|
|
canvas.drawCircle(center, 5, centerNode);
|
|
_drawLabel(canvas, size, center + const Offset(0, 14), 'место', true);
|
|
|
|
for (final axis in axes) {
|
|
final axisOffset = Offset(math.cos(axis.angle), math.sin(axis.angle));
|
|
final axisPoint = center + axisOffset * axisRadius;
|
|
final hasSelectedLeaf = axis.leaves.any(
|
|
(leaf) => selectedTags.contains('${axis.id}:${leaf.id}'),
|
|
);
|
|
|
|
canvas.drawLine(
|
|
center,
|
|
axisPoint,
|
|
hasSelectedLeaf ? selectedLine : baseLine,
|
|
);
|
|
canvas.drawCircle(
|
|
axisPoint,
|
|
hasSelectedLeaf ? 5.5 : 4.5,
|
|
hasSelectedLeaf ? selectedNode : node,
|
|
);
|
|
_drawLabel(
|
|
canvas,
|
|
size,
|
|
axisPoint + axisOffset * 18,
|
|
axis.label,
|
|
hasSelectedLeaf,
|
|
fontSize: 11,
|
|
);
|
|
|
|
for (final leaf in axis.leaves) {
|
|
final leafAngle = axis.angle + leaf.angleOffset;
|
|
final leafOffset = Offset(math.cos(leafAngle), math.sin(leafAngle));
|
|
final leafPoint = center + leafOffset * leafRadius;
|
|
final tag = '${axis.id}:${leaf.id}';
|
|
final selected = selectedTags.contains(tag);
|
|
|
|
canvas.drawLine(
|
|
axisPoint,
|
|
leafPoint,
|
|
selected ? selectedLine : baseLine,
|
|
);
|
|
canvas.drawCircle(
|
|
leafPoint,
|
|
selected ? 8 : 5.5,
|
|
selected ? selectedNode : node,
|
|
);
|
|
_drawLabel(
|
|
canvas,
|
|
size,
|
|
leafPoint + leafOffset * 20,
|
|
leaf.label,
|
|
selected,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawLabel(
|
|
Canvas canvas,
|
|
Size size,
|
|
Offset anchor,
|
|
String label,
|
|
bool selected, {
|
|
double fontSize = 12,
|
|
}) {
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: label,
|
|
style: TextStyle(
|
|
color: selected ? tokens.danger : tokens.snowflakeLabel,
|
|
fontSize: fontSize,
|
|
fontWeight: selected ? FontWeight.w900 : FontWeight.w700,
|
|
height: 1,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
maxLines: 1,
|
|
)..layout(maxWidth: 86);
|
|
final dx = (anchor.dx - painter.width / 2).clamp(
|
|
0.0,
|
|
size.width - painter.width,
|
|
);
|
|
final dy = (anchor.dy - painter.height / 2).clamp(
|
|
0.0,
|
|
size.height - painter.height,
|
|
);
|
|
painter.paint(canvas, Offset(dx, dy));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _AdminOntologySnowflakePainter oldDelegate) {
|
|
return oldDelegate.selectedTags != selectedTags ||
|
|
oldDelegate.tokens != tokens;
|
|
}
|
|
}
|
|
|
|
class _AdminOntologyAxis {
|
|
const _AdminOntologyAxis({
|
|
required this.id,
|
|
required this.label,
|
|
required this.angle,
|
|
required this.leaves,
|
|
});
|
|
|
|
final String id;
|
|
final String label;
|
|
final double angle;
|
|
final List<_AdminOntologyLeaf> leaves;
|
|
}
|
|
|
|
class _AdminOntologyLeaf {
|
|
const _AdminOntologyLeaf(this.id, this.label, this.angleOffset);
|
|
|
|
final String id;
|
|
final String label;
|
|
final double angleOffset;
|
|
}
|
|
|
|
Set<String> _selectedAdminTags(Map<String, dynamic>? analysis) {
|
|
final tags = analysis?['tags'];
|
|
if (tags is! List) {
|
|
return const {};
|
|
}
|
|
return tags.whereType<String>().toSet();
|
|
}
|