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:
|
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,12 +140,13 @@ class MapflowApi {
|
|||||||
telegramId
|
telegramId
|
||||||
username
|
username
|
||||||
firstName
|
firstName
|
||||||
lastName
|
lastName
|
||||||
photoUrl
|
photoUrl
|
||||||
languageCode
|
languageCode
|
||||||
}
|
isAdmin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
''',
|
''',
|
||||||
variables: {'token': token},
|
variables: {'token': token},
|
||||||
);
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,13 +98,26 @@ class _MapContent extends ConsumerWidget {
|
|||||||
SafeArea(
|
SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
child: _UserAvatar(
|
child: Row(
|
||||||
user: state.currentUser,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onLogout: () {
|
children: [
|
||||||
telegram_session.clearMapflowSession();
|
_UserAvatar(
|
||||||
ref.invalidate(placeControllerProvider);
|
user: state.currentUser,
|
||||||
telegram_session.reloadApp();
|
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 }
|
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});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user