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

@@ -7,7 +7,7 @@ on:
jobs: jobs:
build: build:
runs-on: build-host runs-on: builder
env: env:
SERVICE_NAME: flutter SERVICE_NAME: flutter
IMAGE_SHA: gitea.dsrptlab.com/mapflow/flutter:${{ github.sha }} IMAGE_SHA: gitea.dsrptlab.com/mapflow/flutter:${{ github.sha }}
@@ -22,20 +22,9 @@ jobs:
auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')" auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json
- name: Free Docker build space
run: |
set -euo pipefail
docker buildx prune --builder builder --all --max-used-space 40gb -f || true
- name: Build and push image - name: Build and push image
run: | run: |
set -euo pipefail set -euo pipefail
builder="builder"
if ! docker buildx inspect "$builder" >/dev/null 2>&1; then
docker buildx create --name "$builder" --driver docker-container --buildkitd-config /etc/buildkit/buildkitd.toml
fi
docker buildx use "$builder"
docker buildx inspect --bootstrap
docker buildx build \ docker buildx build \
--push \ --push \
--tag "$IMAGE_SHA" \ --tag "$IMAGE_SHA" \
@@ -70,8 +59,3 @@ jobs:
-d "$payload")" -d "$payload")"
cat "$response_file" cat "$response_file"
[ "$status_code" = "200" ] [ "$status_code" = "200" ]
- name: Prune shared BuildKit cache
run: |
set -euo pipefail
docker buildx prune --builder builder --all --max-used-space 40gb -f

View File

@@ -51,6 +51,7 @@ class MapflowApi {
lastName lastName
photoUrl photoUrl
languageCode languageCode
isAdmin
} }
} }
'''); ''');
@@ -73,6 +74,7 @@ class MapflowApi {
lastName lastName
photoUrl photoUrl
languageCode languageCode
isAdmin
} }
} }
} }
@@ -97,6 +99,7 @@ class MapflowApi {
lastName lastName
photoUrl photoUrl
languageCode languageCode
isAdmin
} }
} }
} }
@@ -140,6 +143,7 @@ class MapflowApi {
lastName lastName
photoUrl photoUrl
languageCode languageCode
isAdmin
} }
} }
} }
@@ -255,6 +259,8 @@ class MapflowApi {
required LatLng coordinate, required LatLng coordinate,
required int durationSeconds, required int durationSeconds,
required String audioObjectKey, required String audioObjectKey,
required String audioContentBase64,
required String audioMimeType,
}) async { }) async {
if (!hasTelegramAuth) { if (!hasTelegramAuth) {
throw StateError('Telegram authorization is required.'); throw StateError('Telegram authorization is required.');
@@ -276,11 +282,43 @@ class MapflowApi {
'longitude': coordinate.longitude, 'longitude': coordinate.longitude,
'durationSeconds': durationSeconds, 'durationSeconds': durationSeconds,
'audioObjectKey': audioObjectKey, '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( Future<Map<String, dynamic>> _graphql(
String query, { String query, {
Map<String, dynamic>? variables, Map<String, dynamic>? variables,

View File

@@ -10,6 +10,7 @@ class AppUser {
required this.lastName, required this.lastName,
required this.photoUrl, required this.photoUrl,
required this.languageCode, required this.languageCode,
required this.isAdmin,
}); });
factory AppUser.fromJson(Map<String, dynamic> json) { factory AppUser.fromJson(Map<String, dynamic> json) {
@@ -21,6 +22,7 @@ class AppUser {
lastName: json['lastName'] as String?, lastName: json['lastName'] as String?,
photoUrl: json['photoUrl'] as String?, photoUrl: json['photoUrl'] as String?,
languageCode: json['languageCode'] as String?, languageCode: json['languageCode'] as String?,
isAdmin: json['isAdmin'] as bool? ?? false,
); );
} }
@@ -31,6 +33,51 @@ class AppUser {
final String? lastName; final String? lastName;
final String? photoUrl; final String? photoUrl;
final String? languageCode; 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 { enum PlaceTrait {

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -97,7 +98,10 @@ class _MapContent extends ConsumerWidget {
SafeArea( SafeArea(
child: Align( child: Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: _UserAvatar( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_UserAvatar(
user: state.currentUser, user: state.currentUser,
onLogout: () { onLogout: () {
telegram_session.clearMapflowSession(); telegram_session.clearMapflowSession();
@@ -105,6 +109,16 @@ class _MapContent extends ConsumerWidget {
telegram_session.reloadApp(); telegram_session.reloadApp();
}, },
), ),
if (state.currentUser?.isAdmin == true)
_AdminReviewsButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const AdminVoiceExperiencesScreen(),
),
),
),
],
),
), ),
), ),
if (availableTraits.isNotEmpty) if (availableTraits.isNotEmpty)
@@ -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 } enum _AvatarAction { logout }
class _AvatarImage extends StatelessWidget { class _AvatarImage extends StatelessWidget {
@@ -842,7 +880,18 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
onSelect: (place) async { onSelect: (place) async {
setState(() => _submitting = true); setState(() => _submitting = true);
controller.setReviewPlace(place.name); 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) { if (!context.mounted) {
return; 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 { class _IntroStep extends StatelessWidget {
const _IntroStep({required this.onNext}); 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; final value = state.requireValue;
if (!value.hasTelegramAuth) { if (!value.hasTelegramAuth) {
throw StateError('Открой через Telegram, чтобы оставить голос.'); throw StateError('Открой через Telegram, чтобы оставить голос.');
@@ -180,7 +185,9 @@ class PlaceController extends AsyncNotifier<PlaceState> {
googleName: place.name, googleName: place.name,
coordinate: place.coordinate, coordinate: place.coordinate,
durationSeconds: draft.duration.inSeconds, durationSeconds: draft.duration.inSeconds,
audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}', audioObjectKey: audioObjectKey,
audioContentBase64: audioContentBase64,
audioMimeType: audioMimeType,
); );
final places = await _api.fetchPlaces(); final places = await _api.fetchPlaces();