From b72aa396f8703a2e9d5f0eeccbd4111f06f5226d Mon Sep 17 00:00:00 2001 From: ercmine Date: Fri, 6 Mar 2026 20:50:28 -0600 Subject: [PATCH] Fix ideas controller initialization with async repository --- app/lib/app/router.dart | 10 + .../deck/widgets/card_details_sheet.dart | 57 +++++ app/lib/features/deck/widgets/deck_card.dart | 31 +++ app/lib/features/ideas/ideas_controller.dart | 124 ++++++++++ app/lib/features/ideas/ideas_page.dart | 160 +++++++++++++ app/lib/features/ideas/ideas_state.dart | 50 +++++ .../ideas/widgets/add_idea_sheet.dart | 212 ++++++++++++++++++ .../ideas/widgets/friend_idea_badge.dart | 29 +++ .../ideas/widgets/idea_list_tile.dart | 55 +++++ app/lib/features/sessions/session_page.dart | 5 + app/lib/providers/app_providers.dart | 12 + app/lib/repositories/ideas_repository.dart | 14 +- 12 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 app/lib/features/deck/widgets/card_details_sheet.dart create mode 100644 app/lib/features/deck/widgets/deck_card.dart create mode 100644 app/lib/features/ideas/ideas_controller.dart create mode 100644 app/lib/features/ideas/ideas_page.dart create mode 100644 app/lib/features/ideas/ideas_state.dart create mode 100644 app/lib/features/ideas/widgets/add_idea_sheet.dart create mode 100644 app/lib/features/ideas/widgets/friend_idea_badge.dart create mode 100644 app/lib/features/ideas/widgets/idea_list_tile.dart diff --git a/app/lib/app/router.dart b/app/lib/app/router.dart index 2ca87e59..f8652b9c 100644 --- a/app/lib/app/router.dart +++ b/app/lib/app/router.dart @@ -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'; @@ -107,6 +108,15 @@ final routerProvider = Provider((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', diff --git a/app/lib/features/deck/widgets/card_details_sheet.dart b/app/lib/features/deck/widgets/card_details_sheet.dart new file mode 100644 index 00000000..d91ae644 --- /dev/null +++ b/app/lib/features/deck/widgets/card_details_sheet.dart @@ -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!), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/features/deck/widgets/deck_card.dart b/app/lib/features/deck/widgets/deck_card.dart new file mode 100644 index 00000000..730c25ff --- /dev/null +++ b/app/lib/features/deck/widgets/deck_card.dart @@ -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), + ), + ); + } +} diff --git a/app/lib/features/ideas/ideas_controller.dart b/app/lib/features/ideas/ideas_controller.dart new file mode 100644 index 00000000..1a54e434 --- /dev/null +++ b/app/lib/features/ideas/ideas_controller.dart @@ -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 { + IdeasController({ + required String sessionId, + required Future Function() repositoryResolver, + }) : _repositoryResolver = repositoryResolver, + super(IdeasState.initial(sessionId)); + + static const int _defaultPageSize = 50; + + final Future Function() _repositoryResolver; + + Future initLoad() async { + if (state.ideas.isNotEmpty || state.isLoading) { + return; + } + await refresh(); + } + + Future 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 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 = [...state.ideas, ...response.ideas] + .fold>([], (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 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 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.', + ); + } + } +} diff --git a/app/lib/features/ideas/ideas_page.dart b/app/lib/features/ideas/ideas_page.dart new file mode 100644 index 00000000..3d2de044 --- /dev/null +++ b/app/lib/features/ideas/ideas_page.dart @@ -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 createState() => _IdeasPageState(); +} + +class _IdeasPageState extends ConsumerState { + @override + void initState() { + super.initState(); + Future.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 _openAddIdeaSheet(BuildContext context) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => AddIdeaSheet(sessionId: widget.sessionId), + ); + } + + Future _confirmDelete(BuildContext context, String ideaId) async { + final shouldDelete = await showDialog( + 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')), + ); + } + } + } +} + +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), + ), + ); + } +} diff --git a/app/lib/features/ideas/ideas_state.dart b/app/lib/features/ideas/ideas_state.dart new file mode 100644 index 00000000..4235bffa --- /dev/null +++ b/app/lib/features/ideas/ideas_state.dart @@ -0,0 +1,50 @@ +import '../../models/idea.dart'; + +class IdeasState { + const IdeasState({ + required this.sessionId, + this.isLoading = false, + this.isSubmitting = false, + this.errorMessage, + this.ideas = const [], + this.nextCursor, + this.hasMore = false, + this.lastRefreshAt, + }); + + factory IdeasState.initial(String sessionId) => IdeasState(sessionId: sessionId); + + final String sessionId; + final bool isLoading; + final bool isSubmitting; + final String? errorMessage; + final List ideas; + final String? nextCursor; + final bool hasMore; + final DateTime? lastRefreshAt; + + IdeasState copyWith({ + String? sessionId, + bool? isLoading, + bool? isSubmitting, + String? errorMessage, + bool clearErrorMessage = false, + List? ideas, + String? nextCursor, + bool clearNextCursor = false, + bool? hasMore, + DateTime? lastRefreshAt, + bool clearLastRefreshAt = false, + }) { + return IdeasState( + sessionId: sessionId ?? this.sessionId, + isLoading: isLoading ?? this.isLoading, + isSubmitting: isSubmitting ?? this.isSubmitting, + errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage), + ideas: ideas ?? this.ideas, + nextCursor: clearNextCursor ? null : (nextCursor ?? this.nextCursor), + hasMore: hasMore ?? this.hasMore, + lastRefreshAt: clearLastRefreshAt ? null : (lastRefreshAt ?? this.lastRefreshAt), + ); + } +} diff --git a/app/lib/features/ideas/widgets/add_idea_sheet.dart b/app/lib/features/ideas/widgets/add_idea_sheet.dart new file mode 100644 index 00000000..b0893bb8 --- /dev/null +++ b/app/lib/features/ideas/widgets/add_idea_sheet.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/theme/spacing.dart'; +import '../../../core/contacts/phone_normalize.dart'; +import '../../../models/idea.dart'; +import '../../../models/session_filters.dart'; +import '../../../providers/app_providers.dart'; + +class AddIdeaSheet extends ConsumerStatefulWidget { + const AddIdeaSheet({required this.sessionId, super.key}); + + final String sessionId; + + @override + ConsumerState createState() => _AddIdeaSheetState(); +} + +class _AddIdeaSheetState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _websiteController = TextEditingController(); + final _phoneController = TextEditingController(); + + Category? _selectedCategory; + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _websiteController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(ideasControllerProvider(widget.sessionId)); + + return Padding( + padding: EdgeInsets.only( + left: AppSpacing.m, + right: AppSpacing.m, + top: AppSpacing.m, + bottom: MediaQuery.of(context).viewInsets.bottom + AppSpacing.m, + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Add Idea', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: AppSpacing.m), + TextFormField( + controller: _titleController, + maxLength: 140, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Title *', + border: OutlineInputBorder(), + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return 'Title is required'; + } + if (trimmed.length > 140) { + return 'Title must be at most 140 characters'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.s), + TextFormField( + controller: _descriptionController, + maxLength: 400, + maxLines: 3, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + validator: (value) { + if ((value?.length ?? 0) > 400) { + return 'Description must be at most 400 characters'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.s), + DropdownButtonFormField( + value: _selectedCategory, + decoration: const InputDecoration( + labelText: 'Category', + border: OutlineInputBorder(), + ), + items: Category.values + .map( + (category) => DropdownMenuItem( + value: category, + child: Text(_readableCategory(category)), + ), + ) + .toList(growable: false), + onChanged: (value) => setState(() => _selectedCategory = value), + ), + const SizedBox(height: AppSpacing.s), + TextFormField( + controller: _websiteController, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Website (optional)', + hintText: 'https://example.com', + border: OutlineInputBorder(), + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) { + return 'Website must start with http:// or https://'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.s), + TextFormField( + controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9+()\-\s]'))], + decoration: const InputDecoration( + labelText: 'Phone (optional)', + hintText: '(555) 123-4567', + border: OutlineInputBorder(), + ), + validator: (value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + if (normalizePhoneToE164(trimmed) == null) { + return 'Enter a valid US phone number'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.m), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: state.isSubmitting ? null : _submit, + icon: state.isSubmitting + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_circle_outline), + label: const Text('Add idea'), + ), + ), + ], + ), + ), + ), + ); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final normalizedPhone = normalizePhoneToE164(_phoneController.text.trim()); + final request = CreateIdeaRequest( + title: _titleController.text.trim(), + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + category: _selectedCategory?.name, + websiteLink: _websiteController.text.trim().isEmpty + ? null + : _websiteController.text.trim(), + callLink: normalizedPhone == null ? null : 'tel:$normalizedPhone', + ); + + final success = await ref + .read(ideasControllerProvider(widget.sessionId).notifier) + .createIdea(request); + + if (!mounted) { + return; + } + + if (success) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Idea added successfully')), + ); + } + } + + String _readableCategory(Category category) { + final name = category.name; + return '${name[0].toUpperCase()}${name.substring(1)}'; + } +} diff --git a/app/lib/features/ideas/widgets/friend_idea_badge.dart b/app/lib/features/ideas/widgets/friend_idea_badge.dart new file mode 100644 index 00000000..c9a50a67 --- /dev/null +++ b/app/lib/features/ideas/widgets/friend_idea_badge.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import '../../../models/plan.dart'; + +bool isFriendIdea(Plan plan) { + return plan.source == 'byo' || plan.metadata?['kind'] == 'user_idea'; +} + +class FriendIdeaBadge extends StatelessWidget { + const FriendIdeaBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Friend idea', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/app/lib/features/ideas/widgets/idea_list_tile.dart b/app/lib/features/ideas/widgets/idea_list_tile.dart new file mode 100644 index 00000000..511847a8 --- /dev/null +++ b/app/lib/features/ideas/widgets/idea_list_tile.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../models/idea.dart'; + +class IdeaListTile extends StatelessWidget { + const IdeaListTile({ + required this.idea, + required this.onDelete, + super.key, + }); + + final IdeaItem idea; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final date = DateTime.tryParse(idea.createdAtISO)?.toLocal(); + final subtitleLines = [ + if (idea.description?.trim().isNotEmpty ?? false) idea.description!.trim(), + if (idea.category?.trim().isNotEmpty ?? false) 'Category: ${idea.category}', + if (idea.websiteLink?.trim().isNotEmpty ?? false) 'Website: ${idea.websiteLink}', + if (idea.callLink?.trim().isNotEmpty ?? false) 'Call: ${idea.callLink}', + if (date != null) 'Added ${DateFormat.yMMMd().add_jm().format(date)}', + ]; + + return Dismissible( + key: ValueKey(idea.ideaId), + direction: DismissDirection.endToStart, + confirmDismiss: (_) async { + onDelete(); + return false; + }, + background: Container( + color: Theme.of(context).colorScheme.errorContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: Icon( + Icons.delete, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + child: ListTile( + title: Text(idea.title), + subtitle: subtitleLines.isEmpty ? null : Text(subtitleLines.join('\n')), + isThreeLine: subtitleLines.length > 1, + trailing: IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete idea', + ), + ), + ); + } +} diff --git a/app/lib/features/sessions/session_page.dart b/app/lib/features/sessions/session_page.dart index fa209be2..b2d13d2b 100644 --- a/app/lib/features/sessions/session_page.dart +++ b/app/lib/features/sessions/session_page.dart @@ -73,6 +73,11 @@ class SessionPage extends ConsumerWidget { onPressed: () => context.go('/sessions/${session.sessionId}/settings'), child: const Text('Settings'), ), + const SizedBox(height: AppSpacing.s), + OutlinedButton( + onPressed: () => context.go('/sessions/${session.sessionId}/ideas'), + child: const Text('Ideas'), + ), ], ); }, diff --git a/app/lib/providers/app_providers.dart b/app/lib/providers/app_providers.dart index 9615b565..72e8f7c4 100644 --- a/app/lib/providers/app_providers.dart +++ b/app/lib/providers/app_providers.dart @@ -13,6 +13,8 @@ import '../core/location/location_service.dart'; import '../core/permissions/permission_service.dart'; import '../core/sharing/share_service.dart'; import '../core/store/sessions_store.dart'; +import '../features/ideas/ideas_controller.dart'; +import '../features/ideas/ideas_state.dart'; import '../features/sessions/create_session/create_session_controller.dart'; import '../features/sessions/join_session/join_session_controller.dart'; import '../features/sessions/session_settings/session_settings_controller.dart'; @@ -59,6 +61,16 @@ final ideasRepositoryProvider = FutureProvider((ref) async { return IdeasRepository(apiClient: apiClient); }); + +final ideasControllerProvider = StateNotifierProvider.family( + (ref, sessionId) { + return IdeasController( + sessionId: sessionId, + repositoryResolver: () => ref.read(ideasRepositoryProvider.future), + ); + }, +); + final telemetryRepositoryProvider = FutureProvider((ref) async { final apiClient = await ref.watch(apiClientProvider.future); return TelemetryRepository(apiClient: apiClient); diff --git a/app/lib/repositories/ideas_repository.dart b/app/lib/repositories/ideas_repository.dart index 827cab74..184fd07a 100644 --- a/app/lib/repositories/ideas_repository.dart +++ b/app/lib/repositories/ideas_repository.dart @@ -19,8 +19,18 @@ class IdeasRepository { return CreateIdeaResponse.fromJson(response); } - Future listIdeas(String sessionId) async { - final response = await apiClient.getJson(ApiEndpoints.ideas(sessionId)); + Future listIdeas( + String sessionId, { + String? cursor, + int limit = 50, + }) async { + final response = await apiClient.getJson( + ApiEndpoints.ideas(sessionId), + queryParameters: { + 'cursor': cursor, + 'limit': '$limit', + }, + ); return ListIdeasResponse.fromJson(response); }