Add admin review debug screen
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 14s
Some checks failed
Build and deploy Flutter Web / build (push) Failing after 14s
This commit is contained in:
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: build-host
|
||||
runs-on: builder
|
||||
env:
|
||||
SERVICE_NAME: flutter
|
||||
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')"
|
||||
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
|
||||
run: |
|
||||
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 \
|
||||
--push \
|
||||
--tag "$IMAGE_SHA" \
|
||||
@@ -70,8 +59,3 @@ jobs:
|
||||
-d "$payload")"
|
||||
cat "$response_file"
|
||||
[ "$status_code" = "200" ]
|
||||
|
||||
- name: Prune shared BuildKit cache
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx prune --builder builder --all --max-used-space 40gb -f
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +143,7 @@ class MapflowApi {
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -97,7 +98,10 @@ class _MapContent extends ConsumerWidget {
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _UserAvatar(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_UserAvatar(
|
||||
user: state.currentUser,
|
||||
onLogout: () {
|
||||
telegram_session.clearMapflowSession();
|
||||
@@ -105,6 +109,16 @@ class _MapContent extends ConsumerWidget {
|
||||
telegram_session.reloadApp();
|
||||
},
|
||||
),
|
||||
if (state.currentUser?.isAdmin == true)
|
||||
_AdminReviewsButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AdminVoiceExperiencesScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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 }
|
||||
|
||||
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});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user