-
Notifications
You must be signed in to change notification settings - Fork 0
Add Ideas feature (controller, UI, repository, and routing) #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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!), | ||
| ], | ||
| ], | ||
| ), | ||
| ), | ||
| ); | ||
| } | ||
| } |
| 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), | ||
| ), | ||
| ); | ||
| } | ||
| } |
| 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(); | ||
| 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.', | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The page always shows the "Idea deleted" snackbar after calling 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), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
createIdeaclearsisSubmittingbefore awaitingrefresh, 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 checksisSubmitting. This can create duplicate ideas for a single intended submission.Useful? React with 👍 / 👎.