Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '../features/onboarding/onboarding_permissions_page.dart';
import '../features/onboarding/onboarding_signin_page.dart';
import '../features/sessions/create_session/create_session_page.dart';
import '../features/sessions/join_session/join_session_page.dart';
import '../features/ideas/ideas_page.dart';
import '../features/sessions/session_page.dart';
import '../features/sessions/session_settings/session_settings_page.dart';
import '../features/sessions/sessions_page.dart';
Expand Down Expand Up @@ -107,6 +108,15 @@ final routerProvider = Provider<GoRouter>((ref) {
return SessionPage(sessionId: sessionId);
},
),
GoRoute(
path: '/sessions/:id/ideas',
name: 'session-ideas',
builder: (context, state) {
final sessionId = state.pathParameters['id'] ?? '';
return IdeasPage(sessionId: sessionId);
},
),

GoRoute(
path: '/sessions/:id/settings',
name: 'session-settings',
Expand Down
57 changes: 57 additions & 0 deletions app/lib/features/deck/widgets/card_details_sheet.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';

import '../../../models/plan.dart';
import '../../ideas/widgets/friend_idea_badge.dart';

class CardDetailsSheet extends StatelessWidget {
const CardDetailsSheet({required this.plan, super.key});

final Plan plan;

@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
plan.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
if (isFriendIdea(plan)) const FriendIdeaBadge(),
],
),
const SizedBox(height: 8),
Text(plan.category),
if (isFriendIdea(plan)) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Friend idea\nAdded by someone in your session',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
if (plan.description?.isNotEmpty ?? false) ...[
const SizedBox(height: 12),
Text(plan.description!),
],
],
),
),
);
}
}
31 changes: 31 additions & 0 deletions app/lib/features/deck/widgets/deck_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';

import '../../../models/plan.dart';
import '../../ideas/widgets/friend_idea_badge.dart';

class DeckCard extends StatelessWidget {
const DeckCard({
required this.plan,
this.onTap,
super.key,
});

final Plan plan;
final VoidCallback? onTap;

@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
onTap: onTap,
title: Row(
children: [
Expanded(child: Text(plan.title)),
if (isFriendIdea(plan)) const FriendIdeaBadge(),
],
),
subtitle: Text(plan.category),
),
);
}
}
124 changes: 124 additions & 0 deletions app/lib/features/ideas/ideas_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../core/logging/log.dart';
import '../../models/idea.dart';
import '../../repositories/ideas_repository.dart';
import 'ideas_state.dart';

class IdeasController extends StateNotifier<IdeasState> {
IdeasController({
required String sessionId,
required Future<IdeasRepository> Function() repositoryResolver,
}) : _repositoryResolver = repositoryResolver,
super(IdeasState.initial(sessionId));

static const int _defaultPageSize = 50;

final Future<IdeasRepository> Function() _repositoryResolver;

Future<void> initLoad() async {
if (state.ideas.isNotEmpty || state.isLoading) {
return;
}
await refresh();
}

Future<void> refresh() async {
state = state.copyWith(isLoading: true, clearErrorMessage: true);

try {
final repository = await _repositoryResolver();
final response = await repository.listIdeas(
state.sessionId,
limit: _defaultPageSize,
);
state = state.copyWith(
isLoading: false,
ideas: response.ideas,
nextCursor: response.nextCursor,
hasMore: response.nextCursor?.isNotEmpty ?? false,
lastRefreshAt: DateTime.now(),
);
} catch (error, stackTrace) {
Log.error('Failed to load ideas', error: error, stackTrace: stackTrace);
state = state.copyWith(
isLoading: false,
errorMessage: 'Could not load ideas. Please try again.',
);
}
}

Future<void> loadMore() async {
if (state.isLoading || !state.hasMore || state.nextCursor == null) {
return;
}

state = state.copyWith(isLoading: true, clearErrorMessage: true);
try {
final repository = await _repositoryResolver();
final response = await repository.listIdeas(
state.sessionId,
cursor: state.nextCursor,
limit: _defaultPageSize,
);
final merged = <IdeaItem>[...state.ideas, ...response.ideas]
.fold<List<IdeaItem>>(<IdeaItem>[], (acc, item) {
final exists = acc.any((existing) => existing.ideaId == item.ideaId);
if (!exists) {
acc.add(item);
}
return acc;
});

state = state.copyWith(
isLoading: false,
ideas: merged,
nextCursor: response.nextCursor,
hasMore: response.nextCursor?.isNotEmpty ?? false,
);
} catch (error, stackTrace) {
Log.error('Failed to load more ideas', error: error, stackTrace: stackTrace);
state = state.copyWith(
isLoading: false,
errorMessage: 'Could not load more ideas. Please try again.',
);
}
}

Future<bool> createIdea(CreateIdeaRequest request) async {
if (state.isSubmitting) {
return false;
}

state = state.copyWith(isSubmitting: true, clearErrorMessage: true);
try {
final repository = await _repositoryResolver();
await repository.createIdea(state.sessionId, request);
state = state.copyWith(isSubmitting: false);
await refresh();
Comment on lines +97 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep submit lock until post-create refresh completes

createIdea clears isSubmitting before awaiting refresh, which re-enables the Add button while the first submission is still in progress; on a slow network, users can tap again and send duplicate create requests because the re-entry guard only checks isSubmitting. This can create duplicate ideas for a single intended submission.

Useful? React with 👍 / 👎.

return true;
} catch (error, stackTrace) {
Log.error('Failed to create idea', error: error, stackTrace: stackTrace);
state = state.copyWith(
isSubmitting: false,
errorMessage: 'Could not create idea. Please try again.',
);
return false;
}
}

Future<void> deleteIdea(String ideaId) async {
state = state.copyWith(isLoading: true, clearErrorMessage: true);
try {
final repository = await _repositoryResolver();
await repository.deleteIdea(state.sessionId, ideaId);
await refresh();
} catch (error, stackTrace) {
Log.error('Failed to delete idea', error: error, stackTrace: stackTrace);
state = state.copyWith(
isLoading: false,
errorMessage: 'Could not delete idea. Please try again.',
);
}
}
}
160 changes: 160 additions & 0 deletions app/lib/features/ideas/ideas_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../app/theme/spacing.dart';
import '../../providers/app_providers.dart';
import 'widgets/add_idea_sheet.dart';
import 'widgets/idea_list_tile.dart';

class IdeasPage extends ConsumerStatefulWidget {
const IdeasPage({required this.sessionId, super.key});

final String sessionId;

@override
ConsumerState<IdeasPage> createState() => _IdeasPageState();
}

class _IdeasPageState extends ConsumerState<IdeasPage> {
@override
void initState() {
super.initState();
Future<void>.microtask(
() => ref.read(ideasControllerProvider(widget.sessionId).notifier).initLoad(),
);
}

@override
Widget build(BuildContext context) {
final state = ref.watch(ideasControllerProvider(widget.sessionId));
final controller = ref.read(ideasControllerProvider(widget.sessionId).notifier);

return Scaffold(
appBar: AppBar(title: const Text('Ideas')),
body: RefreshIndicator(
onRefresh: controller.refresh,
child: Builder(
builder: (context) {
if (state.isLoading && state.ideas.isEmpty) {
return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.m),
itemBuilder: (_, __) => const _IdeaSkeletonTile(),
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.s),
itemCount: 6,
);
}

if (state.errorMessage != null && state.ideas.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.m),
children: [
Text(state.errorMessage!),
const SizedBox(height: AppSpacing.s),
FilledButton(
onPressed: controller.refresh,
child: const Text('Retry'),
),
],
);
}

if (state.ideas.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.m),
children: const [
SizedBox(height: AppSpacing.xl),
Center(child: Text('No ideas yet. Add one to get started.')),
],
);
}

return ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(AppSpacing.m),
itemCount: state.ideas.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.s),
itemBuilder: (context, index) {
if (index == state.ideas.length) {
return OutlinedButton(
onPressed: state.isLoading ? null : controller.loadMore,
child: const Text('Load more'),
);
}
final idea = state.ideas[index];
return Card(
child: IdeaListTile(
idea: idea,
onDelete: () => _confirmDelete(context, idea.ideaId),
),
);
},
);
},
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openAddIdeaSheet(context),
icon: const Icon(Icons.add),
label: const Text('Add Idea'),
),
);
}

Future<void> _openAddIdeaSheet(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => AddIdeaSheet(sessionId: widget.sessionId),
);
}

Future<void> _confirmDelete(BuildContext context, String ideaId) async {
final shouldDelete = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete this idea?'),
content: const Text('This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);

if (shouldDelete == true && mounted) {
await ref.read(ideasControllerProvider(widget.sessionId).notifier).deleteIdea(ideaId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Idea deleted')),
Comment on lines +136 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only show delete success after a confirmed delete

The page always shows the "Idea deleted" snackbar after calling deleteIdea, but deleteIdea handles failures internally and does not report success/failure to the caller. If the API delete fails (or refresh fails after delete), users still see a success message, which gives incorrect feedback about data state.

Useful? React with 👍 / 👎.

);
}
}
}
}

class _IdeaSkeletonTile extends StatelessWidget {
const _IdeaSkeletonTile();

@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.surfaceContainerHighest;
return Container(
height: 76,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
);
}
}
Loading