🐛 Bug fixes

This commit is contained in:
2025-08-06 02:51:41 +08:00
parent a6d869ebf6
commit 8c47a59b80
4 changed files with 292 additions and 71 deletions

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -57,11 +59,9 @@ class CreatorPollListScreen extends HookConsumerWidget {
final String pubName; final String pubName;
Future<void> _createPoll(BuildContext context) async { Future<void> _createPoll(BuildContext context) async {
// Use named route defined in router with :name param (creatorPollNew)
final result = await GoRouter.of( final result = await GoRouter.of(
context, context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); ).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
// If PollEditorScreen returns a created SnPoll on success, pop back with it
if (result is SnPoll && context.mounted) { if (result is SnPoll && context.mounted) {
Navigator.of(context).maybePop(result); Navigator.of(context).maybePop(result);
} }
@@ -91,7 +91,7 @@ class CreatorPollListScreen extends HookConsumerWidget {
return endItemView; return endItemView;
} }
final poll = data.items[index]; final poll = data.items[index];
return _CreatorPollItem(poll: poll); return _CreatorPollItem(poll: poll, pubName: pubName);
}, },
), ),
), ),
@@ -103,7 +103,8 @@ class CreatorPollListScreen extends HookConsumerWidget {
} }
class _CreatorPollItem extends StatelessWidget { class _CreatorPollItem extends StatelessWidget {
const _CreatorPollItem({required this.poll}); final String pubName;
const _CreatorPollItem({required this.poll, required this.pubName});
final SnPoll poll; final SnPoll poll;
@@ -143,24 +144,23 @@ class _CreatorPollItem extends StatelessWidget {
], ],
), ),
trailing: PopupMenuButton<String>( trailing: PopupMenuButton<String>(
onSelected: (v) {
switch (v) {
case 'edit':
// Use global router helper if desired
// context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit');
Navigator.of(context).pushNamed(
'creatorPollEdit',
arguments: {
'name': poll.publisher?.name ?? '',
'id': poll.id,
},
);
break;
}
},
itemBuilder: itemBuilder:
(context) => [ (context) => [
const PopupMenuItem(value: 'edit', child: Text('Edit')), PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('Edit'),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': poll.id},
);
},
),
], ],
), ),
onTap: () { onTap: () {

View File

@@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -6,7 +8,6 @@ import 'package:dio/dio.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -63,14 +64,38 @@ class PollEditor extends Notifier<PollEditorState> {
); );
} }
void setEditingId(String? id) { Future<void> setEditingId(BuildContext context, String? id) async {
state = PollEditorState( if (id == null || id.isEmpty) return;
id: id,
title: state.title, showLoadingModal(context);
description: state.description, final dio = ref.read(apiClientProvider);
endedAt: state.endedAt, try {
questions: [...state.questions], final res = await dio.get('/sphere/polls/$id');
);
// Handle both plain object and wrapped response formats.
final dynamic payload = res.data;
final Map<String, dynamic> json =
payload is Map && payload['data'] is Map<String, dynamic>
? Map<String, dynamic>.from(payload['data'] as Map)
: Map<String, dynamic>.from(payload as Map);
final poll = SnPoll.fromJson(json);
state = PollEditorState(
id: poll.id,
title: poll.title,
description: poll.description,
endedAt: poll.endedAt,
questions: poll.questions,
);
} on DioException catch (e) {
log('Failed to load poll $id: ${e.message}');
// Keep state with id set; UI may handle error display.
} catch (e) {
log('Unexpected error loading poll $id: $e');
} finally {
if (context.mounted) hideLoadingModal(context);
}
} }
void addQuestion(SnPollQuestionType type) { void addQuestion(SnPollQuestionType type) {
@@ -313,32 +338,10 @@ class PollEditorScreen extends ConsumerWidget {
// Submit helpers declared before build to avoid forward reference issues // Submit helpers declared before build to avoid forward reference issues
static Future<void> _submitPoll(BuildContext context, WidgetRef ref) async { Future<void> _submitPoll(BuildContext context, WidgetRef ref) async {
final model = ref.watch(pollEditorProvider); final model = ref.watch(pollEditorProvider);
final dio = ref.read(apiClientProvider); final dio = ref.read(apiClientProvider);
// Pick publisher (required)
final pickedPublisher = await showModalBottomSheet<dynamic>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (_) => const PublisherModal(),
);
if (pickedPublisher == null) {
showSnackBar('Publisher is required');
return;
}
final String publisherName =
pickedPublisher.name ?? pickedPublisher['name'] ?? '';
if (publisherName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid publisher selected')),
);
return;
}
// Build payload // Build payload
final body = { final body = {
'title': model.title, 'title': model.title,
@@ -376,33 +379,21 @@ class PollEditorScreen extends ConsumerWidget {
await (isUpdate await (isUpdate
? dio.patch( ? dio.patch(
path, path,
queryParameters: {'pub': publisherName}, queryParameters: {'pub': initialPublisher},
data: body, data: body,
) )
: dio.post( : dio.post(
path, path,
queryParameters: {'pub': publisherName}, queryParameters: {'pub': initialPublisher},
data: body, data: body,
)); ));
ScaffoldMessenger.of(context).showSnackBar( showSnackBar(isUpdate ? 'Poll updated.' : 'Poll created.');
SnackBar(content: Text(isUpdate ? 'Poll updated.' : 'Poll created.')),
);
if (!context.mounted) return; if (!context.mounted) return;
Navigator.of(context).maybePop(res.data); Navigator.of(context).maybePop(res.data);
} on DioException catch (e) {
final msg =
e.response?.data is Map && (e.response!.data['message'] != null)
? e.response!.data['message'].toString()
: e.message ?? 'Network error';
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed: $msg')));
} catch (e) { } catch (e) {
ScaffoldMessenger.of( showErrorAlert(e);
context,
).showSnackBar(SnackBar(content: Text('Unexpected error: $e')));
} }
} }
@@ -417,7 +408,9 @@ class PollEditorScreen extends ConsumerWidget {
// initialize editing state if provided // initialize editing state if provided
if (initialPollId != null && model.id != initialPollId) { if (initialPollId != null && model.id != initialPollId) {
notifier.setEditingId(initialPollId); Future(() {
if (context.mounted) notifier.setEditingId(context, initialPollId);
});
} }
return Scaffold( return Scaffold(
@@ -437,6 +430,7 @@ class PollEditorScreen extends ConsumerWidget {
), ),
body: SafeArea( body: SafeArea(
child: Form( child: Form(
key: ValueKey(model.id),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [

View File

@@ -23,6 +23,7 @@ class PollSubmit extends ConsumerStatefulWidget {
super.key, super.key,
required this.poll, required this.poll,
required this.onSubmit, required this.onSubmit,
required this.stats,
this.initialAnswers, this.initialAnswers,
this.onCancel, this.onCancel,
this.showProgress = true, this.showProgress = true,
@@ -35,6 +36,7 @@ class PollSubmit extends ConsumerStatefulWidget {
/// Optional initial answers, keyed by questionId. /// Optional initial answers, keyed by questionId.
final Map<String, dynamic>? initialAnswers; final Map<String, dynamic>? initialAnswers;
final Map<String, dynamic>? stats;
/// Optional cancel callback. /// Optional cancel callback.
final VoidCallback? onCancel; final VoidCallback? onCancel;
@@ -321,6 +323,153 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
); );
} }
Widget _buildStats(BuildContext context, SnPollQuestion q) {
if (widget.stats == null) return const SizedBox.shrink();
final raw = widget.stats![q.id];
if (raw == null) return const SizedBox.shrink();
Widget? body;
switch (q.type) {
case SnPollQuestionType.rating:
// rating: avg score (double or int)
final avg = (raw['rating'] as num).toDouble();
final theme = Theme.of(context);
body = Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.star, color: Colors.amber.shade600, size: 18),
const SizedBox(width: 6),
Text(
avg.toStringAsFixed(1),
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
break;
case SnPollQuestionType.yesNo:
// yes/no: map {true: count, false: count}
if (raw is Map) {
final int yes =
(raw[true] is int)
? raw[true] as int
: int.tryParse('${raw[true]}') ?? 0;
final int no =
(raw[false] is int)
? raw[false] as int
: int.tryParse('${raw[false]}') ?? 0;
final total = (yes + no).clamp(0, 1 << 31);
final yesPct = total == 0 ? 0.0 : yes / total;
final noPct = total == 0 ? 0.0 : no / total;
final theme = Theme.of(context);
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BarStatRow(
label: 'Yes',
count: yes,
fraction: yesPct,
color: Colors.green.shade600,
),
const SizedBox(height: 6),
_BarStatRow(
label: 'No',
count: no,
fraction: noPct,
color: Colors.red.shade600,
),
const SizedBox(height: 4),
Text(
'Total: $total',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
break;
case SnPollQuestionType.singleChoice:
case SnPollQuestionType.multipleChoice:
// map optionId -> count
if (raw is Map) {
final options = [...?q.options]
..sort((a, b) => a.order.compareTo(b.order));
final List<_OptionCount> items = [];
int total = 0;
for (final opt in options) {
final dynamic v = raw[opt.id];
final int count = v is int ? v : int.tryParse('$v') ?? 0;
total += count;
items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
}
if (items.isNotEmpty) {
items.sort(
(a, b) => b.count.compareTo(a.count),
); // show highest first
}
body = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final it in items)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: _BarStatRow(
label: it.label,
count: it.count,
fraction: total == 0 ? 0 : it.count / total,
),
),
if (items.isNotEmpty)
Text(
'Total: $total',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
break;
case SnPollQuestionType.freeText:
// No stats
break;
}
if (body == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stats',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
body,
],
),
),
),
);
}
Widget _buildBody(BuildContext context) { Widget _buildBody(BuildContext context) {
final q = _current; final q = _current;
switch (q.type) { switch (q.type) {
@@ -467,7 +616,13 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
children: [ children: [
_buildHeader(context), _buildHeader(context),
const SizedBox(height: 12), const SizedBox(height: 12),
_AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)), _AnimatedStep(
key: ValueKey(_current.id),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildBody(context), _buildStats(context, _current)],
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildNavBar(context), _buildNavBar(context),
], ],
@@ -475,6 +630,77 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
} }
} }
class _OptionCount {
final String id;
final String label;
final int count;
const _OptionCount({
required this.id,
required this.label,
required this.count,
});
}
class _BarStatRow extends StatelessWidget {
const _BarStatRow({
required this.label,
required this.count,
required this.fraction,
this.color,
});
final String label;
final int count;
final double fraction;
final Color? color;
@override
Widget build(BuildContext context) {
final barColor = color ?? Theme.of(context).colorScheme.primary;
final bgColor = Theme.of(
context,
).colorScheme.surfaceVariant.withOpacity(0.6);
final fg =
(fraction.isNaN || fraction.isInfinite)
? 0.0
: fraction.clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final filled = width * fg;
return Stack(
children: [
Container(
height: 8,
width: width,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(999),
),
),
Container(
height: 8,
width: filled,
decoration: BoxDecoration(
color: barColor,
borderRadius: BorderRadius.circular(999),
),
),
],
);
},
),
],
);
}
}
/// Simple fade/slide transition between questions. /// Simple fade/slide transition between questions.
class _AnimatedStep extends StatelessWidget { class _AnimatedStep extends StatelessWidget {
const _AnimatedStep({super.key, required this.child}); const _AnimatedStep({super.key, required this.child});

View File

@@ -566,9 +566,10 @@ class PostItem extends HookConsumerWidget {
), ),
child: PollSubmit( child: PollSubmit(
initialAnswers: embedData['poll']?['user_answer']?['answer'], initialAnswers: embedData['poll']?['user_answer']?['answer'],
stats: embedData['poll']?['stats'],
poll: SnPollWithStats.fromJson(embedData['poll']), poll: SnPollWithStats.fromJson(embedData['poll']),
onSubmit: (_) {}, onSubmit: (_) {},
).padding(horizontal: 12, vertical: 8), ).padding(horizontal: 16, vertical: 12),
), ),
_ => const Placeholder(), _ => const Placeholder(),
}, },