Add admin review debug screen
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 14s

This commit is contained in:
Ruslan Bakiev
2026-05-14 08:44:20 +07:00
parent cdf6a43d49
commit fbf9104d2d
5 changed files with 264 additions and 31 deletions

View File

@@ -51,6 +51,7 @@ class MapflowApi {
lastName
photoUrl
languageCode
isAdmin
}
}
''');
@@ -73,6 +74,7 @@ class MapflowApi {
lastName
photoUrl
languageCode
isAdmin
}
}
}
@@ -97,6 +99,7 @@ class MapflowApi {
lastName
photoUrl
languageCode
isAdmin
}
}
}
@@ -137,12 +140,13 @@ class MapflowApi {
telegramId
username
firstName
lastName
photoUrl
languageCode
}
lastName
photoUrl
languageCode
isAdmin
}
}
}
''',
variables: {'token': token},
);
@@ -255,6 +259,8 @@ class MapflowApi {
required LatLng coordinate,
required int durationSeconds,
required String audioObjectKey,
required String audioContentBase64,
required String audioMimeType,
}) async {
if (!hasTelegramAuth) {
throw StateError('Telegram authorization is required.');
@@ -276,11 +282,43 @@ class MapflowApi {
'longitude': coordinate.longitude,
'durationSeconds': durationSeconds,
'audioObjectKey': audioObjectKey,
'audioContentBase64': audioContentBase64,
'audioMimeType': audioMimeType,
},
},
);
}
Future<List<VoiceExperienceDebug>> fetchVoiceExperiences() async {
final data = await _graphql('''
query VoiceExperiences {
voiceExperiences {
id
status
durationSeconds
transcript
analysis
createdAt
place {
name
}
user {
telegramId
username
firstName
}
}
}
''');
final experiences = data['voiceExperiences'] as List<dynamic>;
return experiences
.map(
(item) => VoiceExperienceDebug.fromJson(item as Map<String, dynamic>),
)
.toList();
}
Future<Map<String, dynamic>> _graphql(
String query, {
Map<String, dynamic>? variables,

View File

@@ -10,6 +10,7 @@ class AppUser {
required this.lastName,
required this.photoUrl,
required this.languageCode,
required this.isAdmin,
});
factory AppUser.fromJson(Map<String, dynamic> json) {
@@ -21,6 +22,7 @@ class AppUser {
lastName: json['lastName'] as String?,
photoUrl: json['photoUrl'] as String?,
languageCode: json['languageCode'] as String?,
isAdmin: json['isAdmin'] as bool? ?? false,
);
}
@@ -31,6 +33,51 @@ class AppUser {
final String? lastName;
final String? photoUrl;
final String? languageCode;
final bool isAdmin;
}
class VoiceExperienceDebug {
const VoiceExperienceDebug({
required this.id,
required this.placeName,
required this.userName,
required this.status,
required this.durationSeconds,
required this.transcript,
required this.analysis,
required this.createdAt,
});
factory VoiceExperienceDebug.fromJson(Map<String, dynamic> json) {
final place = json['place'] as Map<String, dynamic>;
final user = json['user'] as Map<String, dynamic>?;
final firstName = user?['firstName'] as String?;
final username = user?['username'] as String?;
final telegramId = user?['telegramId'] as String?;
return VoiceExperienceDebug(
id: json['id'] as String,
placeName: place['name'] as String,
userName: firstName?.trim().isNotEmpty == true
? firstName!
: username?.trim().isNotEmpty == true
? '@$username'
: telegramId ?? '',
status: json['status'] as String,
durationSeconds: json['durationSeconds'] as int,
transcript: json['transcript'] as String?,
analysis: json['analysis'] as Map<String, dynamic>?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String placeName;
final String userName;
final String status;
final int durationSeconds;
final String? transcript;
final Map<String, dynamic>? analysis;
final DateTime createdAt;
}
enum PlaceTrait {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
@@ -97,13 +98,26 @@ class _MapContent extends ConsumerWidget {
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: _UserAvatar(
user: state.currentUser,
onLogout: () {
telegram_session.clearMapflowSession();
ref.invalidate(placeControllerProvider);
telegram_session.reloadApp();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_UserAvatar(
user: state.currentUser,
onLogout: () {
telegram_session.clearMapflowSession();
ref.invalidate(placeControllerProvider);
telegram_session.reloadApp();
},
),
if (state.currentUser?.isAdmin == true)
_AdminReviewsButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const AdminVoiceExperiencesScreen(),
),
),
),
],
),
),
),
@@ -259,6 +273,30 @@ class _UserAvatar extends StatelessWidget {
}
}
class _AdminReviewsButton extends StatelessWidget {
const _AdminReviewsButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.table_rows_outlined, size: 18),
label: const Text('Отзывы'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFFBF5),
foregroundColor: const Color(0xFF17211D),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
),
);
}
}
enum _AvatarAction { logout }
class _AvatarImage extends StatelessWidget {
@@ -842,7 +880,18 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
onSelect: (place) async {
setState(() => _submitting = true);
controller.setReviewPlace(place.name);
await controller.publishReview(place: place);
final file = _waveController.file;
if (file == null) {
throw StateError('Voice recording file is required.');
}
final bytes = await file.readAsBytes();
await controller.publishReview(
place: place,
audioObjectKey:
'web-recording-${DateTime.now().microsecondsSinceEpoch}-${file.name}',
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
);
if (!context.mounted) {
return;
}
@@ -884,6 +933,114 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
}
}
class AdminVoiceExperiencesScreen extends StatefulWidget {
const AdminVoiceExperiencesScreen({super.key});
@override
State<AdminVoiceExperiencesScreen> createState() =>
_AdminVoiceExperiencesScreenState();
}
class _AdminVoiceExperiencesScreenState
extends State<AdminVoiceExperiencesScreen> {
late final Future<List<VoiceExperienceDebug>> _future = MapflowApi()
.fetchVoiceExperiences();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFFFFBF5),
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 tags = review.analysis?['tags'];
final tagText = tags is List ? tags.join(', ') : '';
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
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: const TextStyle(color: Color(0xFF6B6258)),
),
if (review.transcript?.trim().isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(
review.transcript!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
if (tagText.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
tagText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFE11D48)),
),
],
],
),
),
);
}
}
class _IntroStep extends StatelessWidget {
const _IntroStep({required this.onNext});

View File

@@ -167,7 +167,12 @@ class PlaceController extends AsyncNotifier<PlaceState> {
);
}
Future<void> publishReview({required PlaceRecommendation place}) async {
Future<void> publishReview({
required PlaceRecommendation place,
required String audioObjectKey,
required String audioContentBase64,
required String audioMimeType,
}) async {
final value = state.requireValue;
if (!value.hasTelegramAuth) {
throw StateError('Открой через Telegram, чтобы оставить голос.');
@@ -180,7 +185,9 @@ class PlaceController extends AsyncNotifier<PlaceState> {
googleName: place.name,
coordinate: place.coordinate,
durationSeconds: draft.duration.inSeconds,
audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}',
audioObjectKey: audioObjectKey,
audioContentBase64: audioContentBase64,
audioMimeType: audioMimeType,
);
final places = await _api.fetchPlaces();