Phase 2: Household Creation & Invites - 6 plans in 4 waves covering SHARE-01 through SHARE-05 - Data models, repository pattern, and Supabase integration - Database schema with RLS policies for multi-tenant isolation - State management with Riverpod and business logic use cases - Complete UI components for household management - Navigation integration with authentication flow Ready for execution: /gsd:execute-phase 2
679 lines
26 KiB
Markdown
679 lines
26 KiB
Markdown
---
|
|
phase: 02-household-creation
|
|
plan: 05
|
|
type: execute
|
|
wave: 3
|
|
depends_on: [02-04]
|
|
files_modified:
|
|
- lib/features/household/presentation/pages/join_household_page.dart
|
|
- lib/features/household/presentation/pages/household_list_page.dart
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Users can join households using 8-character invite codes with validation"
|
|
- "Household list page shows all user households with clear actions"
|
|
- "Join flow provides immediate feedback on invalid/expired codes"
|
|
- "Household management integrates with navigation and authentication state"
|
|
- "All operations handle loading states and errors gracefully"
|
|
artifacts:
|
|
- path: "lib/features/household/presentation/pages/join_household_page.dart"
|
|
provides: "Household joining interface with invite code validation"
|
|
contains: "JoinHouseholdPage", "invite code input", "validation feedback"
|
|
- path: "lib/features/household/presentation/pages/household_list_page.dart"
|
|
provides: "Household overview and management hub"
|
|
contains: "HouseholdListPage", "household cards", "create/join actions"
|
|
key_links:
|
|
- from: "lib/features/household/presentation/pages/join_household_page.dart"
|
|
to: "lib/features/household/providers/household_provider.dart"
|
|
via: "household joining use case"
|
|
pattern: "ref.read.*householdProvider.*joinHousehold"
|
|
- from: "lib/features/household/presentation/pages/household_list_page.dart"
|
|
to: "lib/features/household/presentation/widgets/household_card.dart"
|
|
via: "household display"
|
|
pattern: "HouseholdCard.*onTap"
|
|
---
|
|
|
|
<objective>
|
|
Complete household management UI with join household flow and household list hub with navigation integration.
|
|
|
|
Purpose: Provide complete household management experience where users can join existing households and manage all their households from one central location.
|
|
Output: Full household management UI ready for navigation integration and user testing.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.opencode/get-shit-done/workflows/execute-plan.md
|
|
@~/.opencode/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
|
|
# UI Pattern References
|
|
@lib/features/authentication/presentation/pages/signup_page.dart
|
|
@lib/features/authentication/presentation/pages/login_page.dart
|
|
@lib/features/authentication/presentation/widgets/auth_button.dart
|
|
@lib/features/household/presentation/widgets/household_card.dart
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Create Join Household Page</name>
|
|
<files>lib/features/household/presentation/pages/join_household_page.dart</files>
|
|
<action>
|
|
Create join household page with invite code validation:
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../../providers/household_provider.dart';
|
|
import '../../domain/exceptions/household_exceptions.dart';
|
|
import '../../domain/entities/household_entity.dart';
|
|
|
|
class JoinHouseholdPage extends ConsumerStatefulWidget {
|
|
const JoinHouseholdPage({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
ConsumerState<JoinHouseholdPage> createState() => _JoinHouseholdPageState();
|
|
}
|
|
|
|
class _JoinHouseholdPageState extends ConsumerState<JoinHouseholdPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _codeController = TextEditingController();
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
HouseholdEntity? _joinedHousehold;
|
|
|
|
@override
|
|
void dispose() {
|
|
_codeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _joinHousehold() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final user = ref.read(authProvider).user;
|
|
if (user == null) throw Exception('User not authenticated');
|
|
|
|
final household = await ref.read(householdProvider.notifier)
|
|
.joinHousehold(_codeController.text.trim(), user.id);
|
|
|
|
setState(() {
|
|
_joinedHousehold = household;
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Auto-close after successful join
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (mounted) {
|
|
Navigator.of(context).pop(household);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = _getErrorMessage(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _getErrorMessage(dynamic error) {
|
|
if (error is HouseholdValidationException) {
|
|
return error.message;
|
|
} else if (error is HouseholdOperationException) {
|
|
return error.message;
|
|
} else if (error is InviteCodeException) {
|
|
return error.message;
|
|
}
|
|
return 'Failed to join household. Please check the invite code and try again.';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Join Household'),
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
|
elevation: 0,
|
|
),
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const SizedBox(height: 32),
|
|
Icon(
|
|
Icons.group_add,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'Join a Household',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Enter the 8-character invite code from your household member',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 48),
|
|
TextFormField(
|
|
controller: _codeController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Invite Code',
|
|
hintText: 'ABCD1234',
|
|
prefixIcon: const Icon(Icons.key),
|
|
helperText: '8 characters, case-insensitive',
|
|
),
|
|
textCapitalization: TextCapitalization.characters,
|
|
textInputAction: TextInputAction.done,
|
|
inputFormatters: [
|
|
UpperCaseTextFormatter(),
|
|
FilteringTextInputFormatter.allow(RegExp(r'[A-Z0-9]')),
|
|
LengthLimitingTextInputFormatter(8),
|
|
],
|
|
onChanged: (value) {
|
|
// Auto-format and move to next field after 8 chars
|
|
if (value.length == 8 && !_isLoading) {
|
|
_formKey.currentState?.validate();
|
|
}
|
|
},
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter an invite code';
|
|
}
|
|
if (value.trim().length != 8) {
|
|
return 'Invite code must be 8 characters';
|
|
}
|
|
return null;
|
|
},
|
|
onFieldSubmitted: (_) => _joinHousehold(),
|
|
),
|
|
const SizedBox(height: 24),
|
|
if (_error != null)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.error,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_joinedHousehold != null)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Successfully joined!',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
'You are now a member of ${_joinedHousehold!.name}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_joinedHousehold == null) ...[
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _joinHousehold,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Join Household'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
OutlinedButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helper formatter for uppercase input
|
|
class UpperCaseTextFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
return TextEditingValue(
|
|
text: newValue.text.toUpperCase(),
|
|
selection: newValue.selection,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
Include auto-formatting, validation, and clear success/error feedback.
|
|
Follow authentication page patterns for consistency.
|
|
</action>
|
|
<verify>flutter analyze lib/features/household/presentation/pages/join_household_page.dart passes</verify>
|
|
<done>Join household page provides invite code validation with immediate feedback</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Create Household List Page</name>
|
|
<files>lib/features/household/presentation/pages/household_list_page.dart</files>
|
|
<action>
|
|
Create household list page as management hub:
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../widgets/household_card.dart';
|
|
import '../widgets/invite_code_dialog.dart';
|
|
import '../../providers/household_provider.dart';
|
|
import '../../domain/entities/household_entity.dart';
|
|
import '../pages/create_household_page.dart';
|
|
import '../pages/join_household_page.dart';
|
|
|
|
class HouseholdListPage extends ConsumerStatefulWidget {
|
|
const HouseholdListPage({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
ConsumerState<HouseholdListPage> createState() => _HouseholdListPageState();
|
|
}
|
|
|
|
class _HouseholdListPageState extends ConsumerState<HouseholdListPage> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_loadHouseholds();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadHouseholds() async {
|
|
final user = ref.read(authProvider).user;
|
|
if (user != null) {
|
|
await ref.read(householdProvider.notifier).loadUserHouseholds(user.id);
|
|
}
|
|
}
|
|
|
|
Future<void> _showCreateHousehold() async {
|
|
final result = await Navigator.of(context).push<HouseholdEntity>(
|
|
MaterialPageRoute(
|
|
builder: (context) => const CreateHouseholdPage(),
|
|
),
|
|
);
|
|
|
|
if (result != null) {
|
|
// Household was created, show success message
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Welcome to ${result.name}!'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showJoinHousehold() async {
|
|
final result = await Navigator.of(context).push<HouseholdEntity>(
|
|
MaterialPageRoute(
|
|
builder: (context) => const JoinHouseholdPage(),
|
|
),
|
|
);
|
|
|
|
if (result != null) {
|
|
// Household was joined, show success message
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('You joined ${result.name}!'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _showInviteDialog(HouseholdEntity household) async {
|
|
await showDialog(
|
|
context: context,
|
|
builder: (context) => InviteCodeDialog(
|
|
householdId: household.id,
|
|
householdName: household.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleHouseholdTap(HouseholdEntity household) async {
|
|
// Set as current household and navigate to home/inventory
|
|
ref.read(currentHouseholdProvider.notifier).state = household;
|
|
|
|
if (mounted) {
|
|
// Navigate to inventory/home page
|
|
Navigator.of(context).pushReplacementNamed('/home');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final user = ref.watch(authProvider).user;
|
|
final householdsAsync = ref.watch(householdProvider);
|
|
final currentHousehold = ref.watch(currentHouseholdProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('My Households'),
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
|
elevation: 0,
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _loadHouseholds,
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: 'Refresh',
|
|
),
|
|
],
|
|
),
|
|
body: RefreshIndicator(
|
|
onRefresh: _loadHouseholds,
|
|
child: householdsAsync.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
error: (error, stack) => Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.error,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load households',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
error.toString(),
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: _loadHouseholds,
|
|
child: const Text('Try Again'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
data: (households) {
|
|
if (households.isEmpty) {
|
|
return _buildEmptyState(context);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
if (currentHousehold != null)
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
margin: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.home,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Current Household',
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
Text(
|
|
currentHousehold.name,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => _handleHouseholdTap(currentHousehold),
|
|
icon: const Icon(Icons.arrow_forward),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.only(bottom: 100),
|
|
itemCount: households.length,
|
|
itemBuilder: (context, index) {
|
|
final household = households[index];
|
|
return HouseholdCard(
|
|
household: household,
|
|
onTap: () => _handleHouseholdTap(household),
|
|
onGenerateInvite: household.canInviteMembers()
|
|
? () => _showInviteDialog(household)
|
|
: null,
|
|
onLeave: household.members.length > 1
|
|
? () => _showLeaveConfirmation(household)
|
|
: null,
|
|
showActions: true,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
floatingActionButton: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
FloatingActionButton.extended(
|
|
onPressed: _showCreateHousehold,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Create'),
|
|
heroTag: 'create',
|
|
),
|
|
const SizedBox(height: 12),
|
|
FloatingActionButton.extended(
|
|
onPressed: _showJoinHousehold,
|
|
icon: const Icon(Icons.group_add),
|
|
label: const Text('Join'),
|
|
heroTag: 'join',
|
|
backgroundColor: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.home_outlined,
|
|
size: 96,
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'No Households Yet',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Create your first household or join an existing one to start sharing your inventory',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 32),
|
|
ElevatedButton.icon(
|
|
onPressed: _showCreateHousehold,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Create Household'),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: _showJoinHousehold,
|
|
icon: const Icon(Icons.group_add),
|
|
label: const Text('Join Household'),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showLeaveConfirmation(HouseholdEntity household) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Leave Household'),
|
|
content: Text('Are you sure you want to leave ${household.name}?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Leave'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
// Handle leaving household
|
|
// This would be implemented in the repository/provider
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Include empty states, loading indicators, error handling, and refresh functionality.
|
|
Provide clear navigation to household creation, joining, and inventory management.
|
|
</action>
|
|
<verify>flutter analyze lib/features/household/presentation/pages/household_list_page.dart passes</verify>
|
|
<done>Household list page provides complete management hub with navigation integration</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. Join household page provides invite code validation with auto-formatting and immediate feedback
|
|
2. Household list page shows all user households with clear management actions
|
|
3. Navigation between create, join, and list flows works seamlessly
|
|
4. Empty states and error handling provide clear user guidance
|
|
5. All components follow existing authentication UI patterns for consistency
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
Household management UI is complete with join flow, list hub, and navigation integration ready for user testing.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-household-creation/02-05-SUMMARY.md` with household UI completion summary
|
|
</output> |