mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: create and edit starter pack screen with repository updates

+1318 -8
+4 -4
docs/tasks/phase-4.md
··· 87 87 88 88 ### Creation & Editing 89 89 90 - - [ ] Create starter pack — name (max 50 graphemes), description, member search, feed picker (up to 3) 91 - - [ ] Creation flow: create reference list → add `listitem` records → create starter pack record 92 - - [ ] Edit starter pack — update name/description/feeds via `putRecord`, add/remove members via `listitem` CRUD 93 - - [ ] Delete starter pack and its backing reference list 90 + - [x] Create starter pack — name (max 50 graphemes), description, member search, feed picker (up to 3) 91 + - [x] Creation flow: create reference list → add `listitem` records → create starter pack record 92 + - [x] Edit starter pack — update name/description/feeds via `putRecord`, add/remove members via `listitem` CRUD 93 + - [x] Delete starter pack and its backing reference list 94 94 95 95 ### Profile Integration 96 96
+12
lib/core/router/app_router.dart
··· 40 40 import 'package:lazurite/features/lists/presentation/my_lists_screen.dart'; 41 41 import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; 42 42 import 'package:lazurite/features/moderation/presentation/screens/moderation_settings_screen.dart'; 43 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 44 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 43 45 import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 46 + import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 44 47 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 45 48 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 46 49 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; ··· 132 135 builder: (context, state) => SavedPostsScreen(accountDid: context.read<String>()), 133 136 ), 134 137 GoRoute(path: '/lists', builder: (context, state) => const MyListsScreen()), 138 + GoRoute( 139 + path: '/create-starter-pack', 140 + builder: (context, state) { 141 + return BlocProvider( 142 + create: (_) => StarterPackBloc(starterPackRepository: context.read<StarterPackRepository>()), 143 + child: CreateStarterPackScreen(userDid: context.read<String>()), 144 + ); 145 + }, 146 + ), 135 147 GoRoute( 136 148 path: '/starter-pack', 137 149 builder: (context, state) {
+6
lib/features/starter_packs/data/starter_pack_repository.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_feed_defs.dart' show GeneratorView; 2 3 import 'package:bluesky/app_bsky_graph_defs.dart'; 3 4 import 'package:bluesky/app_bsky_graph_starterpack.dart'; 4 5 import 'package:lazurite/core/logging/app_logger.dart'; ··· 30 31 ); 31 32 32 33 return response.data.starterPack; 34 + } 35 + 36 + Future<List<GeneratorView>> getSuggestedFeeds({int limit = 50}) async { 37 + final response = await _bluesky.feed.getSuggestedFeeds(limit: limit); 38 + return response.data.feeds as List<GeneratorView>; 33 39 } 34 40 35 41 /// Creates a starter pack using the 3-step flow:
+13
lib/features/starter_packs/presentation/actor_starter_packs_screen.dart
··· 27 27 28 28 @override 29 29 Widget build(BuildContext context) { 30 + String? currentUserDid; 31 + try { 32 + currentUserDid = context.read<String>(); 33 + } catch (_) {} 34 + final isOwnProfile = currentUserDid != null && currentUserDid == actor; 35 + 30 36 return Scaffold( 31 37 appBar: AppBar(title: const Text('Starter Packs')), 38 + floatingActionButton: isOwnProfile 39 + ? FloatingActionButton( 40 + onPressed: () => context.push('/create-starter-pack'), 41 + tooltip: 'Create starter pack', 42 + child: const Icon(Icons.add), 43 + ) 44 + : null, 32 45 body: BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( 33 46 builder: (context, state) { 34 47 if (state.status == ActorStarterPacksStatus.loading) {
+411
lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/features/lists/data/list_repository.dart'; 8 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 9 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 10 + 11 + /// Full-screen form for creating a new starter pack. 12 + /// 13 + /// Expects a [StarterPackBloc] in scope. On successful creation ([StarterPackStatus.loaded]) 14 + /// the screen navigates to the new pack's detail screen. 15 + class CreateStarterPackScreen extends StatefulWidget { 16 + const CreateStarterPackScreen({super.key, required this.userDid}); 17 + 18 + final String userDid; 19 + 20 + @override 21 + State<CreateStarterPackScreen> createState() => _CreateStarterPackScreenState(); 22 + } 23 + 24 + class _CreateStarterPackScreenState extends State<CreateStarterPackScreen> { 25 + final _nameController = TextEditingController(); 26 + final _descController = TextEditingController(); 27 + final _searchController = TextEditingController(); 28 + 29 + final List<ProfileViewBasic> _selectedMembers = []; 30 + final List<GeneratorView> _selectedFeeds = []; 31 + 32 + List<ProfileViewBasic> _searchResults = []; 33 + bool _isSearching = false; 34 + 35 + @override 36 + void initState() { 37 + super.initState(); 38 + _nameController.addListener(() => setState(() {})); 39 + } 40 + 41 + @override 42 + void dispose() { 43 + _nameController.dispose(); 44 + _descController.dispose(); 45 + _searchController.dispose(); 46 + super.dispose(); 47 + } 48 + 49 + bool get _canSave => _nameController.text.trim().isNotEmpty; 50 + 51 + Future<void> _search(String query) async { 52 + if (query.trim().isEmpty) { 53 + setState(() => _searchResults = []); 54 + return; 55 + } 56 + 57 + setState(() => _isSearching = true); 58 + 59 + try { 60 + final results = await context.read<ListRepository>().searchActorsTypeahead(query: query.trim(), limit: 10); 61 + if (mounted) setState(() => _searchResults = results); 62 + } catch (_) { 63 + if (mounted) setState(() => _searchResults = []); 64 + } finally { 65 + if (mounted) setState(() => _isSearching = false); 66 + } 67 + } 68 + 69 + void _addMember(ProfileViewBasic profile) { 70 + if (_selectedMembers.any((m) => m.did == profile.did)) return; 71 + setState(() { 72 + _selectedMembers.add(profile); 73 + _searchController.clear(); 74 + _searchResults = []; 75 + }); 76 + } 77 + 78 + void _removeMember(String did) { 79 + setState(() => _selectedMembers.removeWhere((m) => m.did == did)); 80 + } 81 + 82 + void _removeFeed(AtUri uri) { 83 + setState(() => _selectedFeeds.removeWhere((f) => f.uri == uri)); 84 + } 85 + 86 + Future<void> _showFeedPicker() async { 87 + final selected = await showModalBottomSheet<GeneratorView>( 88 + context: context, 89 + isScrollControlled: true, 90 + builder: (_) => _FeedPickerSheet( 91 + alreadySelected: _selectedFeeds.map((f) => f.uri).toSet(), 92 + starterPackRepository: context.read<StarterPackRepository>(), 93 + ), 94 + ); 95 + if (selected != null && mounted) { 96 + setState(() => _selectedFeeds.add(selected)); 97 + } 98 + } 99 + 100 + void _save() { 101 + context.read<StarterPackBloc>().add( 102 + StarterPackCreated( 103 + userDid: widget.userDid, 104 + name: _nameController.text.trim(), 105 + description: _descController.text.trim().isEmpty ? null : _descController.text.trim(), 106 + memberDids: _selectedMembers.map((m) => m.did).toList(), 107 + feedUris: _selectedFeeds.map((f) => f.uri).toList(), 108 + ), 109 + ); 110 + } 111 + 112 + @override 113 + Widget build(BuildContext context) { 114 + return BlocListener<StarterPackBloc, StarterPackState>( 115 + listenWhen: (prev, curr) => 116 + (prev.status == StarterPackStatus.loading && curr.status == StarterPackStatus.loaded) || 117 + (prev.status == StarterPackStatus.loading && curr.status == StarterPackStatus.error), 118 + listener: (context, state) { 119 + if (state.status == StarterPackStatus.loaded && state.packUri != null) { 120 + context.pushReplacement('/starter-pack?uri=${Uri.encodeComponent(state.packUri!.toString())}'); 121 + } else if (state.status == StarterPackStatus.error) { 122 + ScaffoldMessenger.of(context).showSnackBar( 123 + SnackBar( 124 + content: Text(state.errorMessage ?? 'Failed to create starter pack'), 125 + behavior: SnackBarBehavior.floating, 126 + ), 127 + ); 128 + } 129 + }, 130 + child: BlocBuilder<StarterPackBloc, StarterPackState>( 131 + builder: (context, state) { 132 + final isCreating = state.status == StarterPackStatus.loading; 133 + 134 + return Scaffold( 135 + appBar: AppBar( 136 + title: const Text('New Starter Pack'), 137 + actions: [ 138 + if (isCreating) 139 + const Padding( 140 + padding: EdgeInsets.symmetric(horizontal: 16), 141 + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), 142 + ) 143 + else 144 + TextButton(onPressed: _canSave ? _save : null, child: const Text('Create')), 145 + ], 146 + ), 147 + body: ListView( 148 + padding: const EdgeInsets.all(16), 149 + children: [ 150 + TextField( 151 + controller: _nameController, 152 + decoration: const InputDecoration( 153 + labelText: 'Name', 154 + border: OutlineInputBorder(), 155 + helperText: 'Required, max 50 characters', 156 + ), 157 + maxLength: 50, 158 + textCapitalization: TextCapitalization.sentences, 159 + enabled: !isCreating, 160 + ), 161 + const SizedBox(height: 16), 162 + TextField( 163 + controller: _descController, 164 + decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), 165 + maxLength: 300, 166 + maxLines: 3, 167 + textCapitalization: TextCapitalization.sentences, 168 + enabled: !isCreating, 169 + ), 170 + const SizedBox(height: 24), 171 + _buildMembersSection(context, isCreating), 172 + const SizedBox(height: 24), 173 + _buildFeedsSection(context, isCreating), 174 + ], 175 + ), 176 + ); 177 + }, 178 + ), 179 + ); 180 + } 181 + 182 + Widget _buildMembersSection(BuildContext context, bool isCreating) { 183 + final colorScheme = Theme.of(context).colorScheme; 184 + final textTheme = Theme.of(context).textTheme; 185 + 186 + return Column( 187 + crossAxisAlignment: CrossAxisAlignment.start, 188 + children: [ 189 + Text('Members', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), 190 + const SizedBox(height: 8), 191 + TextField( 192 + controller: _searchController, 193 + decoration: InputDecoration( 194 + hintText: 'Search for people to add', 195 + prefixIcon: const Icon(Icons.search), 196 + suffixIcon: _isSearching 197 + ? const Padding( 198 + padding: EdgeInsets.all(12), 199 + child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), 200 + ) 201 + : (_searchController.text.isNotEmpty 202 + ? IconButton( 203 + icon: const Icon(Icons.clear), 204 + onPressed: () { 205 + _searchController.clear(); 206 + setState(() => _searchResults = []); 207 + }, 208 + ) 209 + : null), 210 + border: const OutlineInputBorder(), 211 + ), 212 + onChanged: _search, 213 + enabled: !isCreating, 214 + ), 215 + if (_searchResults.isNotEmpty) ...[ 216 + const SizedBox(height: 4), 217 + Container( 218 + decoration: BoxDecoration( 219 + border: Border.all(color: colorScheme.outlineVariant), 220 + borderRadius: BorderRadius.circular(8), 221 + ), 222 + child: Column( 223 + children: _searchResults.map((profile) { 224 + final isAlreadyAdded = _selectedMembers.any((m) => m.did == profile.did); 225 + return ListTile( 226 + dense: true, 227 + leading: CircleAvatar( 228 + radius: 16, 229 + backgroundColor: colorScheme.surfaceContainerHighest, 230 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 231 + child: profile.avatar == null 232 + ? Text( 233 + (profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle) 234 + .substring(0, 1) 235 + .toUpperCase(), 236 + style: const TextStyle(fontSize: 12), 237 + ) 238 + : null, 239 + ), 240 + title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 241 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 242 + trailing: isAlreadyAdded 243 + ? Icon(Icons.check_circle, color: colorScheme.primary) 244 + : IconButton(icon: const Icon(Icons.add_circle_outline), onPressed: () => _addMember(profile)), 245 + ); 246 + }).toList(), 247 + ), 248 + ), 249 + ], 250 + if (_selectedMembers.isNotEmpty) ...[ 251 + const SizedBox(height: 12), 252 + Wrap( 253 + spacing: 8, 254 + runSpacing: 8, 255 + children: _selectedMembers.map((member) { 256 + return Chip( 257 + avatar: CircleAvatar( 258 + backgroundImage: member.avatar != null ? NetworkImage(member.avatar!) : null, 259 + backgroundColor: colorScheme.surfaceContainerHighest, 260 + child: member.avatar == null 261 + ? Text( 262 + (member.displayName?.isNotEmpty == true ? member.displayName! : member.handle) 263 + .substring(0, 1) 264 + .toUpperCase(), 265 + style: const TextStyle(fontSize: 10), 266 + ) 267 + : null, 268 + ), 269 + label: Text(member.displayName ?? member.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 270 + onDeleted: isCreating ? null : () => _removeMember(member.did), 271 + ); 272 + }).toList(), 273 + ), 274 + ], 275 + ], 276 + ); 277 + } 278 + 279 + Widget _buildFeedsSection(BuildContext context, bool isCreating) { 280 + final textTheme = Theme.of(context).textTheme; 281 + final colorScheme = Theme.of(context).colorScheme; 282 + 283 + return Column( 284 + crossAxisAlignment: CrossAxisAlignment.start, 285 + children: [ 286 + Row( 287 + children: [ 288 + Text('Feeds', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), 289 + const SizedBox(width: 8), 290 + Text('(up to 3)', style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), 291 + ], 292 + ), 293 + const SizedBox(height: 8), 294 + for (final feed in _selectedFeeds) 295 + ListTile( 296 + contentPadding: EdgeInsets.zero, 297 + leading: CircleAvatar( 298 + radius: 20, 299 + backgroundColor: colorScheme.surfaceContainerHighest, 300 + backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 301 + child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 302 + ), 303 + title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 304 + subtitle: feed.description != null 305 + ? Text(feed.description!, maxLines: 1, overflow: TextOverflow.ellipsis) 306 + : null, 307 + trailing: isCreating 308 + ? null 309 + : IconButton( 310 + icon: Icon(Icons.remove_circle_outline, color: colorScheme.error), 311 + onPressed: () => _removeFeed(feed.uri), 312 + ), 313 + ), 314 + if (_selectedFeeds.length < 3) 315 + OutlinedButton.icon( 316 + onPressed: isCreating ? null : _showFeedPicker, 317 + icon: const Icon(Icons.add), 318 + label: const Text('Add feed'), 319 + ), 320 + ], 321 + ); 322 + } 323 + } 324 + 325 + class _FeedPickerSheet extends StatefulWidget { 326 + const _FeedPickerSheet({required this.alreadySelected, required this.starterPackRepository}); 327 + 328 + final Set<AtUri> alreadySelected; 329 + final StarterPackRepository starterPackRepository; 330 + 331 + @override 332 + State<_FeedPickerSheet> createState() => _FeedPickerSheetState(); 333 + } 334 + 335 + class _FeedPickerSheetState extends State<_FeedPickerSheet> { 336 + List<GeneratorView>? _feeds; 337 + String? _error; 338 + 339 + @override 340 + void initState() { 341 + super.initState(); 342 + _loadFeeds(); 343 + } 344 + 345 + Future<void> _loadFeeds() async { 346 + try { 347 + final feeds = await widget.starterPackRepository.getSuggestedFeeds(limit: 50); 348 + if (mounted) setState(() => _feeds = feeds); 349 + } catch (e) { 350 + if (mounted) setState(() => _error = 'Failed to load feeds'); 351 + } 352 + } 353 + 354 + @override 355 + Widget build(BuildContext context) { 356 + final colorScheme = Theme.of(context).colorScheme; 357 + 358 + return DraggableScrollableSheet( 359 + initialChildSize: 0.6, 360 + maxChildSize: 0.9, 361 + minChildSize: 0.4, 362 + expand: false, 363 + builder: (context, scrollController) { 364 + return Column( 365 + children: [ 366 + Padding( 367 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 368 + child: Row( 369 + children: [ 370 + Text('Select a feed', style: Theme.of(context).textTheme.titleMedium), 371 + const Spacer(), 372 + IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), 373 + ], 374 + ), 375 + ), 376 + const Divider(height: 1), 377 + Expanded( 378 + child: _error != null 379 + ? Center(child: Text(_error!)) 380 + : _feeds == null 381 + ? const Center(child: CircularProgressIndicator()) 382 + : ListView.builder( 383 + controller: scrollController, 384 + itemCount: _feeds!.length, 385 + itemBuilder: (context, index) { 386 + final feed = _feeds![index]; 387 + final isSelected = widget.alreadySelected.contains(feed.uri); 388 + 389 + return ListTile( 390 + leading: CircleAvatar( 391 + backgroundColor: colorScheme.surfaceContainerHighest, 392 + backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 393 + child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 394 + ), 395 + title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 396 + subtitle: feed.description != null 397 + ? Text(feed.description!, maxLines: 1, overflow: TextOverflow.ellipsis) 398 + : null, 399 + trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : null, 400 + enabled: !isSelected, 401 + onTap: isSelected ? null : () => Navigator.pop(context, feed), 402 + ); 403 + }, 404 + ), 405 + ), 406 + ], 407 + ); 408 + }, 409 + ); 410 + } 411 + }
+288
lib/features/starter_packs/presentation/starter_pack_detail_screen.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_feed_defs.dart' show GeneratorView; 2 3 import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 3 4 import 'package:flutter/material.dart' hide ListView; 4 5 import 'package:flutter/material.dart' as material show ListView; ··· 58 59 }, 59 60 builder: (context, state) { 60 61 final pack = state.starterPack; 62 + String? currentUserDid; 63 + try { 64 + currentUserDid = context.read<String>(); 65 + } catch (_) {} 66 + final isCreator = currentUserDid != null && pack?.creator.did == currentUserDid; 61 67 62 68 return Scaffold( 63 69 body: CustomScrollView( ··· 72 78 const Padding( 73 79 padding: EdgeInsets.symmetric(horizontal: 16), 74 80 child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), 81 + ) 82 + else if (isCreator && pack != null) 83 + PopupMenuButton<_PackAction>( 84 + onSelected: (action) => _handleAction(context, action, state, pack), 85 + itemBuilder: (_) => const [ 86 + PopupMenuItem(value: _PackAction.edit, child: Text('Edit')), 87 + PopupMenuItem(value: _PackAction.delete, child: Text('Delete')), 88 + ], 75 89 ), 76 90 ], 77 91 ), ··· 104 118 ), 105 119 ], 106 120 ), 121 + ); 122 + }, 123 + ); 124 + } 125 + 126 + void _handleAction( 127 + BuildContext context, 128 + _PackAction action, 129 + StarterPackState state, 130 + bsky_graph.StarterPackView pack, 131 + ) { 132 + switch (action) { 133 + case _PackAction.edit: 134 + _showEditDialog(context, pack); 135 + case _PackAction.delete: 136 + _showDeleteConfirmation(context); 137 + } 138 + } 139 + 140 + Future<void> _showEditDialog(BuildContext context, bsky_graph.StarterPackView pack) async { 141 + final bloc = context.read<StarterPackBloc>(); 142 + final currentFeeds = pack.feeds ?? const []; 143 + await showDialog<void>( 144 + context: context, 145 + builder: (_) => BlocProvider.value( 146 + value: bloc, 147 + child: _EditStarterPackDialog( 148 + initialName: (pack.record['name'] as String?) ?? '', 149 + initialDescription: pack.record['description'] as String?, 150 + initialFeeds: currentFeeds, 151 + ), 152 + ), 153 + ); 154 + } 155 + 156 + Future<void> _showDeleteConfirmation(BuildContext context) async { 157 + final bloc = context.read<StarterPackBloc>(); 158 + String? currentUserDid; 159 + try { 160 + currentUserDid = context.read<String>(); 161 + } catch (_) {} 162 + if (currentUserDid == null) return; 163 + 164 + final confirmed = await showDialog<bool>( 165 + context: context, 166 + builder: (_) => AlertDialog( 167 + title: const Text('Delete starter pack'), 168 + content: const Text( 169 + 'This will permanently delete this starter pack and its backing list. This cannot be undone.', 170 + ), 171 + actions: [ 172 + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), 173 + FilledButton( 174 + style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), 175 + onPressed: () => Navigator.pop(context, true), 176 + child: const Text('Delete'), 177 + ), 178 + ], 179 + ), 180 + ); 181 + 182 + if (confirmed == true) { 183 + bloc.add(StarterPackDeleted(userDid: currentUserDid)); 184 + } 185 + } 186 + } 187 + 188 + enum _PackAction { edit, delete } 189 + 190 + class _EditStarterPackDialog extends StatefulWidget { 191 + const _EditStarterPackDialog({required this.initialName, this.initialDescription, this.initialFeeds = const []}); 192 + 193 + final String initialName; 194 + final String? initialDescription; 195 + final List<GeneratorView> initialFeeds; 196 + 197 + @override 198 + State<_EditStarterPackDialog> createState() => _EditStarterPackDialogState(); 199 + } 200 + 201 + class _EditStarterPackDialogState extends State<_EditStarterPackDialog> { 202 + late final TextEditingController _nameController; 203 + late final TextEditingController _descController; 204 + late final List<GeneratorView> _feeds; 205 + 206 + @override 207 + void initState() { 208 + super.initState(); 209 + _nameController = TextEditingController(text: widget.initialName); 210 + _descController = TextEditingController(text: widget.initialDescription ?? ''); 211 + _feeds = List.of(widget.initialFeeds); 212 + _nameController.addListener(() => setState(() {})); 213 + } 214 + 215 + @override 216 + void dispose() { 217 + _nameController.dispose(); 218 + _descController.dispose(); 219 + super.dispose(); 220 + } 221 + 222 + Future<void> _showFeedPicker() async { 223 + StarterPackRepository? repo; 224 + try { 225 + repo = context.read<StarterPackRepository>(); 226 + } catch (_) {} 227 + if (repo == null) return; 228 + 229 + final selected = await showModalBottomSheet<GeneratorView>( 230 + context: context, 231 + isScrollControlled: true, 232 + builder: (_) => _FeedPickerSheet(alreadySelected: _feeds.map((f) => f.uri).toSet(), starterPackRepository: repo!), 233 + ); 234 + if (selected != null && mounted) { 235 + setState(() => _feeds.add(selected)); 236 + } 237 + } 238 + 239 + void _save() { 240 + final name = _nameController.text.trim(); 241 + if (name.isEmpty) return; 242 + 243 + context.read<StarterPackBloc>().add( 244 + StarterPackUpdated( 245 + name: name, 246 + description: _descController.text.trim().isEmpty ? null : _descController.text.trim(), 247 + feedUris: _feeds.map((f) => f.uri).toList(), 248 + ), 249 + ); 250 + Navigator.pop(context); 251 + } 252 + 253 + @override 254 + Widget build(BuildContext context) { 255 + final colorScheme = Theme.of(context).colorScheme; 256 + 257 + return AlertDialog( 258 + title: const Text('Edit starter pack'), 259 + content: SizedBox( 260 + width: double.maxFinite, 261 + child: SingleChildScrollView( 262 + child: Column( 263 + mainAxisSize: MainAxisSize.min, 264 + crossAxisAlignment: CrossAxisAlignment.start, 265 + children: [ 266 + TextField( 267 + controller: _nameController, 268 + decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), 269 + maxLength: 50, 270 + textCapitalization: TextCapitalization.sentences, 271 + ), 272 + const SizedBox(height: 12), 273 + TextField( 274 + controller: _descController, 275 + decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), 276 + maxLength: 300, 277 + maxLines: 3, 278 + textCapitalization: TextCapitalization.sentences, 279 + ), 280 + const SizedBox(height: 12), 281 + Text('Feeds', style: Theme.of(context).textTheme.labelLarge), 282 + const SizedBox(height: 8), 283 + for (final feed in _feeds) 284 + ListTile( 285 + dense: true, 286 + contentPadding: EdgeInsets.zero, 287 + leading: CircleAvatar( 288 + radius: 16, 289 + backgroundColor: colorScheme.surfaceContainerHighest, 290 + backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 291 + child: feed.avatar == null ? const Icon(Icons.rss_feed, size: 14) : null, 292 + ), 293 + title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 294 + trailing: IconButton( 295 + icon: Icon(Icons.remove_circle_outline, color: colorScheme.error, size: 20), 296 + onPressed: () => setState(() => _feeds.remove(feed)), 297 + ), 298 + ), 299 + if (_feeds.length < 3) 300 + TextButton.icon(onPressed: _showFeedPicker, icon: const Icon(Icons.add), label: const Text('Add feed')), 301 + ], 302 + ), 303 + ), 304 + ), 305 + actions: [ 306 + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 307 + FilledButton(onPressed: _nameController.text.trim().isEmpty ? null : _save, child: const Text('Save')), 308 + ], 309 + ); 310 + } 311 + } 312 + 313 + class _FeedPickerSheet extends StatefulWidget { 314 + const _FeedPickerSheet({required this.alreadySelected, required this.starterPackRepository}); 315 + 316 + final Set<AtUri> alreadySelected; 317 + final StarterPackRepository starterPackRepository; 318 + 319 + @override 320 + State<_FeedPickerSheet> createState() => _FeedPickerSheetState(); 321 + } 322 + 323 + class _FeedPickerSheetState extends State<_FeedPickerSheet> { 324 + List<GeneratorView>? _feeds; 325 + String? _error; 326 + 327 + @override 328 + void initState() { 329 + super.initState(); 330 + _loadFeeds(); 331 + } 332 + 333 + Future<void> _loadFeeds() async { 334 + try { 335 + final feeds = await widget.starterPackRepository.getSuggestedFeeds(limit: 50); 336 + if (mounted) setState(() => _feeds = feeds); 337 + } catch (e) { 338 + if (mounted) setState(() => _error = 'Failed to load feeds'); 339 + } 340 + } 341 + 342 + @override 343 + Widget build(BuildContext context) { 344 + final colorScheme = Theme.of(context).colorScheme; 345 + 346 + return DraggableScrollableSheet( 347 + initialChildSize: 0.6, 348 + maxChildSize: 0.9, 349 + minChildSize: 0.4, 350 + expand: false, 351 + builder: (context, scrollController) { 352 + return Column( 353 + children: [ 354 + Padding( 355 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 356 + child: Row( 357 + children: [ 358 + Text('Select a feed', style: Theme.of(context).textTheme.titleMedium), 359 + const Spacer(), 360 + IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), 361 + ], 362 + ), 363 + ), 364 + const Divider(height: 1), 365 + Expanded( 366 + child: _error != null 367 + ? Center(child: Text(_error!)) 368 + : _feeds == null 369 + ? const Center(child: CircularProgressIndicator()) 370 + : material.ListView.builder( 371 + controller: scrollController, 372 + itemCount: _feeds!.length, 373 + itemBuilder: (context, index) { 374 + final feed = _feeds![index]; 375 + final isSelected = widget.alreadySelected.contains(feed.uri); 376 + 377 + return ListTile( 378 + leading: CircleAvatar( 379 + backgroundColor: colorScheme.surfaceContainerHighest, 380 + backgroundImage: feed.avatar != null ? NetworkImage(feed.avatar!) : null, 381 + child: feed.avatar == null ? const Icon(Icons.rss_feed) : null, 382 + ), 383 + title: Text(feed.displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 384 + subtitle: feed.description != null 385 + ? Text(feed.description!, maxLines: 1, overflow: TextOverflow.ellipsis) 386 + : null, 387 + trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : null, 388 + enabled: !isSelected, 389 + onTap: isSelected ? null : () => Navigator.pop(context, feed), 390 + ); 391 + }, 392 + ), 393 + ), 394 + ], 107 395 ); 108 396 }, 109 397 );
+50 -2
test/features/starter_packs/presentation/actor_starter_packs_screen_test.dart
··· 37 37 ); 38 38 } 39 39 40 - Widget buildSubject() { 40 + Widget buildSubject({String? currentUserDid}) { 41 41 return MultiRepositoryProvider( 42 - providers: [RepositoryProvider<StarterPackRepository>.value(value: mockRepository)], 42 + providers: [ 43 + RepositoryProvider<StarterPackRepository>.value(value: mockRepository), 44 + if (currentUserDid != null) RepositoryProvider.value(value: currentUserDid), 45 + ], 43 46 child: const MaterialApp(home: ActorStarterPacksScreen(actor: actor)), 44 47 ); 45 48 } ··· 124 127 await tester.pumpAndSettle(); 125 128 126 129 expect(find.text('Starter Packs'), findsOneWidget); 130 + }); 131 + 132 + testWidgets('shows FAB when viewing own profile', (tester) async { 133 + when( 134 + () => mockRepository.getActorStarterPacks( 135 + actor: any(named: 'actor'), 136 + cursor: any(named: 'cursor'), 137 + limit: any(named: 'limit'), 138 + ), 139 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [])); 140 + 141 + await tester.pumpWidget(buildSubject(currentUserDid: actor)); 142 + await tester.pumpAndSettle(); 143 + 144 + expect(find.byType(FloatingActionButton), findsOneWidget); 145 + }); 146 + 147 + testWidgets('does not show FAB when viewing another user profile', (tester) async { 148 + when( 149 + () => mockRepository.getActorStarterPacks( 150 + actor: any(named: 'actor'), 151 + cursor: any(named: 'cursor'), 152 + limit: any(named: 'limit'), 153 + ), 154 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [])); 155 + 156 + await tester.pumpWidget(buildSubject(currentUserDid: 'did:plc:other-user')); 157 + await tester.pumpAndSettle(); 158 + 159 + expect(find.byType(FloatingActionButton), findsNothing); 160 + }); 161 + 162 + testWidgets('does not show FAB when no current user DID is available', (tester) async { 163 + when( 164 + () => mockRepository.getActorStarterPacks( 165 + actor: any(named: 'actor'), 166 + cursor: any(named: 'cursor'), 167 + limit: any(named: 'limit'), 168 + ), 169 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [])); 170 + 171 + await tester.pumpWidget(buildSubject()); 172 + await tester.pumpAndSettle(); 173 + 174 + expect(find.byType(FloatingActionButton), findsNothing); 127 175 }); 128 176 }
+322
test/features/starter_packs/presentation/create_edit_starter_pack_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' show AtUri; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/features/lists/data/list_repository.dart'; 9 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 10 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 11 + import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 12 + import 'package:mocktail/mocktail.dart'; 13 + 14 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 15 + 16 + class MockListRepository extends Mock implements ListRepository {} 17 + 18 + class MockStarterPackBloc extends Mock implements StarterPackBloc {} 19 + 20 + void main() { 21 + late MockStarterPackRepository mockRepo; 22 + late MockListRepository mockListRepo; 23 + 24 + const userDid = 'did:plc:user'; 25 + final packUri = AtUri.parse('at://did:plc:user/app.bsky.graph.starterpack/pack-1'); 26 + final refListUri = AtUri.parse('at://did:plc:user/app.bsky.graph.list/list-1'); 27 + 28 + setUpAll(() { 29 + registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.starterpack/fallback')); 30 + }); 31 + 32 + setUp(() { 33 + mockRepo = MockStarterPackRepository(); 34 + mockListRepo = MockListRepository(); 35 + }); 36 + 37 + StarterPackView buildPackView() { 38 + return StarterPackView( 39 + uri: packUri, 40 + cid: 'cid-pack', 41 + record: const { 42 + r'$type': 'app.bsky.graph.starterpack', 43 + 'name': 'My Pack', 44 + 'list': 'at://did:plc:user/app.bsky.graph.list/list-1', 45 + 'createdAt': '2026-03-22T00:00:00.000Z', 46 + }, 47 + creator: const ProfileViewBasic(did: userDid, handle: 'user.bsky.social'), 48 + list: ListViewBasic( 49 + uri: refListUri, 50 + cid: 'cid-list', 51 + name: 'Starter Pack Members', 52 + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), 53 + ), 54 + indexedAt: DateTime.utc(2026, 3, 22), 55 + ); 56 + } 57 + 58 + Widget buildSubject() { 59 + return MultiRepositoryProvider( 60 + providers: [ 61 + RepositoryProvider<StarterPackRepository>.value(value: mockRepo), 62 + RepositoryProvider<ListRepository>.value(value: mockListRepo), 63 + RepositoryProvider.value(value: userDid), 64 + ], 65 + child: BlocProvider( 66 + create: (_) => StarterPackBloc(starterPackRepository: mockRepo), 67 + child: const MaterialApp(home: CreateStarterPackScreen(userDid: userDid)), 68 + ), 69 + ); 70 + } 71 + 72 + testWidgets('shows name field and Create button', (tester) async { 73 + await tester.pumpWidget(buildSubject()); 74 + 75 + expect(find.widgetWithText(TextField, 'Name'), findsOneWidget); 76 + expect(find.text('Create'), findsOneWidget); 77 + }); 78 + 79 + testWidgets('Create button is disabled when name is empty', (tester) async { 80 + await tester.pumpWidget(buildSubject()); 81 + 82 + final createButton = tester.widget<TextButton>(find.widgetWithText(TextButton, 'Create')); 83 + expect(createButton.onPressed, isNull); 84 + }); 85 + 86 + testWidgets('Create button is enabled when name is non-empty', (tester) async { 87 + await tester.pumpWidget(buildSubject()); 88 + 89 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'My Pack'); 90 + await tester.pump(); 91 + 92 + final createButton = tester.widget<TextButton>(find.widgetWithText(TextButton, 'Create')); 93 + expect(createButton.onPressed, isNotNull); 94 + }); 95 + 96 + testWidgets('shows Members and Feeds sections', (tester) async { 97 + await tester.pumpWidget(buildSubject()); 98 + 99 + expect(find.text('Members'), findsOneWidget); 100 + expect(find.text('Feeds'), findsOneWidget); 101 + }); 102 + 103 + testWidgets('adds a member via search and displays as chip', (tester) async { 104 + const profile = ProfileViewBasic(did: 'did:plc:member', handle: 'member.bsky.social', displayName: 'Alice'); 105 + 106 + when( 107 + () => mockListRepo.searchActorsTypeahead( 108 + query: any(named: 'query'), 109 + limit: any(named: 'limit'), 110 + ), 111 + ).thenAnswer((_) async => [profile]); 112 + 113 + await tester.pumpWidget(buildSubject()); 114 + 115 + await tester.enterText(find.widgetWithText(TextField, 'Search for people to add'), 'alice'); 116 + await tester.pumpAndSettle(); 117 + 118 + expect(find.text('Alice'), findsWidgets); 119 + 120 + await tester.tap(find.byIcon(Icons.add_circle_outline)); 121 + await tester.pump(); 122 + 123 + expect(find.text('Alice'), findsOneWidget); 124 + expect(find.byType(Chip), findsOneWidget); 125 + }); 126 + 127 + testWidgets('dispatches StarterPackCreated on Create tap', (tester) async { 128 + when( 129 + () => mockRepo.createStarterPack( 130 + userDid: any(named: 'userDid'), 131 + name: any(named: 'name'), 132 + description: any(named: 'description'), 133 + memberDids: any(named: 'memberDids'), 134 + feedUris: any(named: 'feedUris'), 135 + ), 136 + ).thenAnswer((_) async { 137 + await Future<void>.delayed(const Duration(hours: 1)); 138 + return packUri; 139 + }); 140 + 141 + await tester.pumpWidget(buildSubject()); 142 + 143 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'My Pack'); 144 + await tester.pump(); 145 + 146 + await tester.tap(find.text('Create')); 147 + await tester.pump(); 148 + 149 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 150 + await tester.pump(const Duration(hours: 2)); 151 + }); 152 + 153 + testWidgets('shows error snackbar when creation fails', (tester) async { 154 + when( 155 + () => mockRepo.createStarterPack( 156 + userDid: any(named: 'userDid'), 157 + name: any(named: 'name'), 158 + description: any(named: 'description'), 159 + memberDids: any(named: 'memberDids'), 160 + feedUris: any(named: 'feedUris'), 161 + ), 162 + ).thenThrow(Exception('network error')); 163 + 164 + await tester.pumpWidget(buildSubject()); 165 + 166 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'My Pack'); 167 + await tester.pump(); 168 + 169 + await tester.tap(find.text('Create')); 170 + await tester.pumpAndSettle(); 171 + 172 + expect(find.byType(SnackBar), findsOneWidget); 173 + }); 174 + 175 + testWidgets('shows loading spinner on submit and hides Create button', (tester) async { 176 + when( 177 + () => mockRepo.createStarterPack( 178 + userDid: any(named: 'userDid'), 179 + name: any(named: 'name'), 180 + description: any(named: 'description'), 181 + memberDids: any(named: 'memberDids'), 182 + feedUris: any(named: 'feedUris'), 183 + ), 184 + ).thenAnswer((_) async { 185 + await Future<void>.delayed(const Duration(hours: 1)); 186 + return packUri; 187 + }); 188 + 189 + await tester.pumpWidget(buildSubject()); 190 + 191 + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'New Pack'); 192 + await tester.pump(); 193 + 194 + await tester.tap(find.text('Create')); 195 + await tester.pump(); 196 + 197 + expect(find.text('Create'), findsNothing); 198 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 199 + 200 + await tester.pump(const Duration(hours: 2)); 201 + }); 202 + 203 + testWidgets('shows Add feed button when fewer than 3 feeds selected', (tester) async { 204 + await tester.pumpWidget(buildSubject()); 205 + 206 + expect(find.text('Add feed'), findsOneWidget); 207 + }); 208 + 209 + testWidgets('feed picker sheet shows loading indicator while fetching', (tester) async { 210 + when(() => mockRepo.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async { 211 + await Future<void>.delayed(const Duration(hours: 1)); 212 + return const []; 213 + }); 214 + 215 + await tester.pumpWidget(buildSubject()); 216 + 217 + await tester.tap(find.text('Add feed')); 218 + await tester.pump(); 219 + 220 + expect(find.text('Select a feed'), findsOneWidget); 221 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 222 + await tester.pump(const Duration(hours: 2)); 223 + }); 224 + 225 + testWidgets('feed picker shows suggested feeds', (tester) async { 226 + final feed = GeneratorView( 227 + uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/test-feed'), 228 + cid: 'cid-feed', 229 + did: 'did:plc:feedcreator', 230 + creator: const ProfileView(did: 'did:plc:feedcreator', handle: 'creator.bsky.social'), 231 + displayName: 'Cool Feed', 232 + indexedAt: DateTime.utc(2026, 3, 22), 233 + ); 234 + 235 + when(() => mockRepo.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => [feed]); 236 + 237 + await tester.pumpWidget(buildSubject()); 238 + 239 + await tester.tap(find.text('Add feed')); 240 + await tester.pumpAndSettle(); 241 + 242 + expect(find.text('Cool Feed'), findsOneWidget); 243 + }); 244 + 245 + testWidgets('selecting a feed from picker adds it to the list', (tester) async { 246 + final feed = GeneratorView( 247 + uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/test-feed'), 248 + cid: 'cid-feed', 249 + did: 'did:plc:feedcreator', 250 + creator: const ProfileView(did: 'did:plc:feedcreator', handle: 'creator.bsky.social'), 251 + displayName: 'Cool Feed', 252 + indexedAt: DateTime.utc(2026, 3, 22), 253 + ); 254 + 255 + when(() => mockRepo.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => [feed]); 256 + 257 + await tester.pumpWidget(buildSubject()); 258 + 259 + await tester.tap(find.text('Add feed')); 260 + await tester.pumpAndSettle(); 261 + 262 + await tester.tap(find.text('Cool Feed')); 263 + await tester.pumpAndSettle(); 264 + 265 + expect(find.text('Cool Feed'), findsOneWidget); 266 + }); 267 + 268 + testWidgets('hides Add feed button when 3 feeds are selected', (tester) async { 269 + final feeds = List.generate( 270 + 3, 271 + (i) => GeneratorView( 272 + uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/feed-$i'), 273 + cid: 'cid-feed-$i', 274 + did: 'did:plc:feedcreator', 275 + creator: const ProfileView(did: 'did:plc:feedcreator', handle: 'creator.bsky.social'), 276 + displayName: 'Feed $i', 277 + indexedAt: DateTime.utc(2026, 3, 22), 278 + ), 279 + ); 280 + 281 + when(() => mockRepo.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => feeds); 282 + 283 + await tester.pumpWidget(buildSubject()); 284 + 285 + for (var i = 0; i < 3; i++) { 286 + await tester.tap(find.text('Add feed')); 287 + await tester.pumpAndSettle(); 288 + await tester.tap(find.text('Feed $i').last); 289 + await tester.pumpAndSettle(); 290 + } 291 + 292 + expect(find.text('Add feed'), findsNothing); 293 + }); 294 + 295 + testWidgets('can remove a selected feed', (tester) async { 296 + final feed = GeneratorView( 297 + uri: AtUri.parse('at://did:plc:creator/app.bsky.feed.generator/test-feed'), 298 + cid: 'cid-feed', 299 + did: 'did:plc:feedcreator', 300 + creator: const ProfileView(did: 'did:plc:feedcreator', handle: 'creator.bsky.social'), 301 + displayName: 'Cool Feed', 302 + indexedAt: DateTime.utc(2026, 3, 22), 303 + ); 304 + 305 + when(() => mockRepo.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => [feed]); 306 + 307 + await tester.pumpWidget(buildSubject()); 308 + 309 + await tester.tap(find.text('Add feed')); 310 + await tester.pumpAndSettle(); 311 + 312 + await tester.tap(find.text('Cool Feed')); 313 + await tester.pumpAndSettle(); 314 + 315 + expect(find.text('Cool Feed'), findsOneWidget); 316 + 317 + await tester.tap(find.byIcon(Icons.remove_circle_outline)); 318 + await tester.pump(); 319 + 320 + expect(find.text('Cool Feed'), findsNothing); 321 + }); 322 + }
+212 -2
test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 8 10 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 9 11 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 10 12 import 'package:mocktail/mocktail.dart'; ··· 55 57 ); 56 58 } 57 59 58 - Widget buildSubject() { 60 + Widget buildSubject({String? currentUserDid}) { 59 61 return MultiRepositoryProvider( 60 - providers: [RepositoryProvider<StarterPackRepository>.value(value: mockRepository)], 62 + providers: [ 63 + RepositoryProvider<StarterPackRepository>.value(value: mockRepository), 64 + if (currentUserDid != null) RepositoryProvider.value(value: currentUserDid), 65 + ], 61 66 child: MaterialApp(home: StarterPackDetailScreen(packUri: packUri)), 62 67 ); 63 68 } 64 69 70 + Widget buildSubjectAsCreator() => buildSubject(currentUserDid: 'did:plc:creator'); 71 + 72 + /// Builds with GoRouter so navigation calls (context.canPop, context.pop) work. 73 + Widget buildSubjectWithRouter({String? currentUserDid}) { 74 + final router = GoRouter( 75 + routes: [ 76 + GoRoute( 77 + path: '/', 78 + builder: (_, __) => MultiRepositoryProvider( 79 + providers: [ 80 + RepositoryProvider<StarterPackRepository>.value(value: mockRepository), 81 + if (currentUserDid != null) RepositoryProvider.value(value: currentUserDid), 82 + ], 83 + child: StarterPackDetailScreen(packUri: packUri), 84 + ), 85 + ), 86 + ], 87 + ); 88 + return MaterialApp.router(routerConfig: router); 89 + } 90 + 65 91 testWidgets('shows loading state initially', (tester) async { 66 92 when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async { 67 93 await Future<void>.delayed(const Duration(hours: 1)); ··· 148 174 await tester.pumpAndSettle(); 149 175 150 176 expect(find.text('Retry'), findsOneWidget); 177 + }); 178 + 179 + testWidgets('shows overflow menu when current user is the creator', (tester) async { 180 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 181 + 182 + await tester.pumpWidget(buildSubjectAsCreator()); 183 + await tester.pumpAndSettle(); 184 + 185 + expect(find.byWidgetPredicate((w) => w is PopupMenuButton), findsOneWidget); 186 + }); 187 + 188 + testWidgets('does not show overflow menu when user is not the creator', (tester) async { 189 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 190 + 191 + await tester.pumpWidget(buildSubject(currentUserDid: 'did:plc:other-user')); 192 + await tester.pumpAndSettle(); 193 + 194 + expect(find.byType(PopupMenuButton<dynamic>), findsNothing); 195 + }); 196 + 197 + testWidgets('does not show overflow menu when no user DID is available', (tester) async { 198 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 199 + 200 + await tester.pumpWidget(buildSubject()); 201 + await tester.pumpAndSettle(); 202 + 203 + expect(find.byType(PopupMenuButton<dynamic>), findsNothing); 204 + }); 205 + 206 + testWidgets('overflow menu has Edit and Delete options', (tester) async { 207 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 208 + 209 + await tester.pumpWidget(buildSubjectAsCreator()); 210 + await tester.pumpAndSettle(); 211 + 212 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 213 + await tester.pumpAndSettle(); 214 + 215 + expect(find.text('Edit'), findsOneWidget); 216 + expect(find.text('Delete'), findsOneWidget); 217 + }); 218 + 219 + testWidgets('tapping Edit shows the edit dialog', (tester) async { 220 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 221 + when(() => mockRepository.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => []); 222 + 223 + await tester.pumpWidget(buildSubjectAsCreator()); 224 + await tester.pumpAndSettle(); 225 + 226 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 227 + await tester.pumpAndSettle(); 228 + 229 + await tester.tap(find.text('Edit')); 230 + await tester.pumpAndSettle(); 231 + 232 + expect(find.text('Edit starter pack'), findsOneWidget); 233 + expect(find.text('Save'), findsOneWidget); 234 + expect(find.text('Cancel'), findsOneWidget); 235 + }); 236 + 237 + testWidgets('edit dialog is pre-filled with current pack name', (tester) async { 238 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 239 + when(() => mockRepository.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => []); 240 + 241 + await tester.pumpWidget(buildSubjectAsCreator()); 242 + await tester.pumpAndSettle(); 243 + 244 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 245 + await tester.pumpAndSettle(); 246 + 247 + await tester.tap(find.text('Edit')); 248 + await tester.pumpAndSettle(); 249 + 250 + final nameField = tester.widget<TextField>( 251 + find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)).first, 252 + ); 253 + expect(nameField.controller?.text, 'My Starter Pack'); 254 + }); 255 + 256 + testWidgets('Save in edit dialog dispatches StarterPackUpdated', (tester) async { 257 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 258 + when(() => mockRepository.getSuggestedFeeds(limit: any(named: 'limit'))).thenAnswer((_) async => []); 259 + when( 260 + () => mockRepository.updateStarterPack( 261 + packUri: any(named: 'packUri'), 262 + referenceListUri: any(named: 'referenceListUri'), 263 + name: any(named: 'name'), 264 + description: any(named: 'description'), 265 + feedUris: any(named: 'feedUris'), 266 + ), 267 + ).thenAnswer((_) async {}); 268 + 269 + await tester.pumpWidget(buildSubjectAsCreator()); 270 + await tester.pumpAndSettle(); 271 + 272 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 273 + await tester.pumpAndSettle(); 274 + 275 + await tester.tap(find.text('Edit')); 276 + await tester.pumpAndSettle(); 277 + 278 + await tester.tap(find.text('Save')); 279 + await tester.pumpAndSettle(); 280 + 281 + verify( 282 + () => mockRepository.updateStarterPack( 283 + packUri: any(named: 'packUri'), 284 + referenceListUri: any(named: 'referenceListUri'), 285 + name: any(named: 'name'), 286 + description: any(named: 'description'), 287 + feedUris: any(named: 'feedUris'), 288 + ), 289 + ).called(1); 290 + }); 291 + 292 + testWidgets('tapping Delete shows confirmation dialog', (tester) async { 293 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 294 + 295 + await tester.pumpWidget(buildSubjectAsCreator()); 296 + await tester.pumpAndSettle(); 297 + 298 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 299 + await tester.pumpAndSettle(); 300 + 301 + await tester.tap(find.text('Delete')); 302 + await tester.pumpAndSettle(); 303 + 304 + expect(find.text('Delete starter pack'), findsOneWidget); 305 + }); 306 + 307 + testWidgets('confirming delete dispatches StarterPackDeleted', (tester) async { 308 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 309 + when( 310 + () => mockRepository.deleteStarterPack( 311 + packUri: any(named: 'packUri'), 312 + referenceListUri: any(named: 'referenceListUri'), 313 + userDid: any(named: 'userDid'), 314 + ), 315 + ).thenAnswer((_) async {}); 316 + 317 + // Use router so context.canPop() works when state transitions to deleted. 318 + await tester.pumpWidget(buildSubjectWithRouter(currentUserDid: 'did:plc:creator')); 319 + await tester.pumpAndSettle(); 320 + 321 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 322 + await tester.pumpAndSettle(); 323 + 324 + await tester.tap(find.text('Delete')); 325 + await tester.pumpAndSettle(); 326 + 327 + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); 328 + await tester.pumpAndSettle(); 329 + 330 + verify( 331 + () => mockRepository.deleteStarterPack( 332 + packUri: any(named: 'packUri'), 333 + referenceListUri: any(named: 'referenceListUri'), 334 + userDid: any(named: 'userDid'), 335 + ), 336 + ).called(1); 337 + }); 338 + 339 + testWidgets('cancelling delete dialog does not dispatch delete', (tester) async { 340 + when(() => mockRepository.getStarterPack(starterPackUri: packUri)).thenAnswer((_) async => buildPack()); 341 + 342 + await tester.pumpWidget(buildSubjectAsCreator()); 343 + await tester.pumpAndSettle(); 344 + 345 + await tester.tap(find.byWidgetPredicate((w) => w is PopupMenuButton)); 346 + await tester.pumpAndSettle(); 347 + 348 + await tester.tap(find.text('Delete')); 349 + await tester.pumpAndSettle(); 350 + 351 + await tester.tap(find.text('Cancel')); 352 + await tester.pumpAndSettle(); 353 + 354 + verifyNever( 355 + () => mockRepository.deleteStarterPack( 356 + packUri: any(named: 'packUri'), 357 + referenceListUri: any(named: 'referenceListUri'), 358 + userDid: any(named: 'userDid'), 359 + ), 360 + ); 151 361 }); 152 362 153 363 testWidgets('shows Follow all loading indicator while following', (tester) async {