Files
voidea/flutter/lib/features/ideas/screens/idea_view_screen.dart
T

412 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/ideas_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../models/idea.dart';
import '../../../models/collaboration.dart';
import '../../../widgets/vote_buttons.dart';
import '../../../widgets/comment_thread.dart';
import '../../../widgets/funnel_status_badge.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/client.dart';
import '../../../providers/disk_provider.dart';
class IdeaViewScreen extends ConsumerStatefulWidget {
final String id;
const IdeaViewScreen({super.key, required this.id});
@override
ConsumerState<IdeaViewScreen> createState() => _IdeaViewScreenState();
}
class _IdeaViewScreenState extends ConsumerState<IdeaViewScreen> {
final _commentController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(ideasProvider.notifier).loadIdea(widget.id);
ref.read(ideaCommentsProvider(widget.id).notifier).loadComments();
ref.read(ideaActivityProvider(widget.id).notifier).loadActivities();
});
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
void _showPresentationOptions(BuildContext context) {
final diskState = ref.read(diskProvider);
final hasDrive = diskState.connectedProviders.isNotEmpty;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Создать презентацию',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.download, color: VoIdeaColors.primary),
title: const Text('Скачать HTML'),
subtitle: const Text('Откроется в браузере'),
onTap: () {
Navigator.pop(ctx);
_generateAndDownload();
},
),
if (hasDrive)
...diskState.connectedProviders.map((p) {
final provider = p['provider'] as String? ?? '';
return ListTile(
leading: Icon(_providerIcon(provider),
color: VoIdeaColors.success),
title: Text('Отправить на ${_providerLabel(provider)}'),
subtitle: Text(p['email'] ?? ''),
onTap: () {
Navigator.pop(ctx);
_generateAndUpload(provider);
},
);
}),
if (!hasDrive)
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Подключите облачный диск в настройках, чтобы сохранять презентации в облако',
style: TextStyle(fontSize: 12, color: VoIdeaColors.muted),
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}
IconData _providerIcon(String provider) {
switch (provider) {
case 'google': return Icons.g_mobiledata;
case 'yandex': return Icons.cloud;
case 'apple': return Icons.apple;
default: return Icons.cloud_outlined;
}
}
String _providerLabel(String provider) {
switch (provider) {
case 'google': return 'Google Диск';
case 'yandex': return 'Яндекс Диск';
case 'apple': return 'iCloud';
default: return provider;
}
}
Future<void> _generateAndDownload() async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=download',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Презентация скачана')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e')),
);
}
}
}
Future<void> _generateAndUpload(String provider) async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=drive&drive_provider=$provider',
);
if (mounted) {
final data = resp.data as Map<String, dynamic>;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Презентация отправлена на ${_providerLabel(provider)}'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка загрузки: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final ideaState = ref.watch(ideasProvider);
final commentsState = ref.watch(ideaCommentsProvider(widget.id));
final activityState = ref.watch(ideaActivityProvider(widget.id));
final authState = ref.watch(authProvider);
final ideasNotifier = ref.read(ideasProvider.notifier);
final commentsNotifier =
ref.read(ideaCommentsProvider(widget.id).notifier);
final currentUserId = authState.user?.id ?? '';
final idea = ideaState.selectedIdea;
if (ideaState.isLoading && idea == null) {
return Scaffold(
appBar: AppBar(),
body: const LoadingIndicator(),
);
}
if (idea == null) {
return Scaffold(
appBar: AppBar(),
body: ErrorDisplay(
message: ideaState.error ?? 'Идея не найдена',
onRetry: () =>
ref.read(ideasProvider.notifier).loadIdea(widget.id),
),
);
}
final funnelIndex = AppConstants.funnelStates.indexOf(idea.funnelStatus ?? '');
final prevStatus = funnelIndex > 0
? AppConstants.funnelStates[funnelIndex - 1]
: null;
final nextStatus = funnelIndex < AppConstants.funnelStates.length - 1
? AppConstants.funnelStates[funnelIndex + 1]
: null;
return Scaffold(
appBar: AppBar(
title: const Text('Идея'),
actions: [
if (idea.userId == currentUserId)
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/ideas/${widget.id}/edit'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
idea.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (idea.funnelStatus != null)
FunnelStatusBadge(status: idea.funnelStatus),
],
).animate().fadeIn(),
const SizedBox(height: 8),
Row(
children: [
Text(
idea.userName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
const SizedBox(width: 16),
Text(
DateFormat('d MMM yyyy').format(idea.createdAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
],
).animate().fadeIn(delay: 100.ms),
const SizedBox(height: 16),
if (idea.description.isNotEmpty)
Text(
idea.description,
style: Theme.of(context).textTheme.bodyLarge,
).animate().fadeIn(delay: 200.ms),
const SizedBox(height: 24),
Row(
children: [
VoteButtons(
upvotes: idea.upvotes,
downvotes: idea.downvotes,
onUpvote: () =>
commentsNotifier.toggleVote(widget.id),
onDownvote: () =>
commentsNotifier.toggleVote(widget.id),
),
const Spacer(),
if (prevStatus != null)
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, prevStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[prevStatus]}',
),
if (nextStatus != null)
IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, nextStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[nextStatus]}',
),
],
).animate().fadeIn(delay: 300.ms),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.slideshow, size: 18),
label: const Text('Презентация'),
onPressed: () => _showPresentationOptions(context),
),
),
],
),
const Divider(height: 32),
Text(
'Комментарии',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Напишите комментарий...',
isDense: true,
),
textCapitalization: TextCapitalization.sentences,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
color: VoIdeaColors.primary,
onPressed: () {
if (_commentController.text.isNotEmpty) {
commentsNotifier
.addComment(_commentController.text.trim())
.then((_) =>
_commentController.clear());
}
},
),
],
),
const SizedBox(height: 16),
if (commentsState.isLoading)
const LoadingIndicator()
else
CommentThread(
comments: commentsState.comments,
currentUserId: currentUserId,
onAddComment: (body, {parentId}) =>
commentsNotifier.addComment(body, parentId: parentId),
).animate().fadeIn(delay: 500.ms),
const Divider(height: 32),
Text(
'Активность',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 600.ms),
const SizedBox(height: 12),
if (activityState.isLoading)
const LoadingIndicator()
else
...activityState.activities.map(
(a) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor:
VoIdeaColors.primary.withOpacity(0.1),
child: Text(
a.userName.isNotEmpty
? a.userName[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 10,
color: VoIdeaColors.primary),
),
),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: a.userName,
style: const TextStyle(
fontWeight: FontWeight.w500),
),
TextSpan(
text: ' ${a.action}',
),
if (a.description != null)
TextSpan(
text: ': ${a.description}',
style: TextStyle(
color: VoIdeaColors.muted),
),
],
),
style: const TextStyle(fontSize: 13),
),
),
Text(
DateFormat('HH:mm').format(a.createdAt),
style: const TextStyle(
fontSize: 11, color: VoIdeaColors.muted),
),
],
),
),
),
],
),
),
);
}
}