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

feat: Add composer UI for configuring post threading settings, content warnings, and language.

+933 -1
+201 -1
lib/src/features/composer/presentation/screens/composer_screen.dart
··· 6 6 import 'package:lazurite/src/core/utils/error_message.dart'; 7 7 import 'package:lazurite/src/features/composer/application/composer_notifier.dart'; 8 8 import 'package:lazurite/src/features/composer/application/composer_providers.dart'; 9 + import 'package:lazurite/src/features/composer/domain/draft.dart'; 10 + import 'package:lazurite/src/features/composer/presentation/screens/gif_picker_screen.dart'; 9 11 import 'package:lazurite/src/features/composer/presentation/widgets/alt_text_editor_sheet.dart'; 10 12 import 'package:lazurite/src/features/composer/presentation/widgets/character_count_meter.dart'; 11 13 import 'package:lazurite/src/features/composer/presentation/widgets/composer_text_field.dart'; 14 + import 'package:lazurite/src/features/composer/presentation/widgets/content_warning_button.dart'; 15 + import 'package:lazurite/src/features/composer/presentation/widgets/content_warning_sheet.dart'; 16 + import 'package:lazurite/src/features/composer/presentation/widgets/language_pill.dart'; 17 + import 'package:lazurite/src/features/composer/presentation/widgets/language_selector_sheet.dart'; 12 18 import 'package:lazurite/src/features/composer/presentation/widgets/media_picker_row.dart'; 13 19 import 'package:lazurite/src/features/composer/presentation/widgets/publish_button.dart'; 14 20 import 'package:lazurite/src/features/composer/presentation/widgets/quote_post_card.dart'; 15 21 import 'package:lazurite/src/features/composer/presentation/widgets/reply_context_card.dart'; 16 - import 'package:lazurite/src/features/composer/presentation/screens/gif_picker_screen.dart'; 22 + import 'package:lazurite/src/features/composer/presentation/widgets/threading_settings_sheet.dart'; 17 23 18 24 /// Maximum character limit for posts (grapheme clusters). 19 25 const int kMaxPostLength = 300; ··· 395 401 } 396 402 } 397 403 404 + Future<void> _openLanguageSelector() async { 405 + final composerState = ref.read(composerProvider(_args)).asData?.value; 406 + final currentLangs = composerState?.draft?.langs ?? []; 407 + 408 + final result = await showModalBottomSheet<List<String>>( 409 + context: context, 410 + isScrollControlled: true, 411 + showDragHandle: true, 412 + builder: (context) => LanguageSelectorSheet( 413 + selectedLanguages: currentLangs, 414 + onSelectionChanged: (langs) { 415 + ref.read(composerProvider(_args).notifier).setLanguages(langs); 416 + }, 417 + ), 418 + ); 419 + 420 + if (result != null && mounted) { 421 + await ref.read(composerProvider(_args).notifier).setLanguages(result); 422 + } 423 + } 424 + 425 + Future<void> _openContentWarningSelector() async { 426 + final composerState = ref.read(composerProvider(_args)).asData?.value; 427 + final currentLabels = composerState?.draft?.labels ?? []; 428 + 429 + final result = await showModalBottomSheet<List<String>>( 430 + context: context, 431 + isScrollControlled: true, 432 + showDragHandle: true, 433 + builder: (context) => ContentWarningSheet( 434 + selectedLabels: currentLabels, 435 + onSelectionChanged: (labels) { 436 + ref.read(composerProvider(_args).notifier).setLabels(labels); 437 + }, 438 + ), 439 + ); 440 + 441 + if (result != null && mounted) { 442 + await ref.read(composerProvider(_args).notifier).setLabels(result); 443 + } 444 + } 445 + 446 + Future<void> _openThreadingSettings() async { 447 + final composerState = ref.read(composerProvider(_args)).asData?.value; 448 + final currentThreadGate = composerState?.draft?.threadGateType; 449 + final currentQuoteDisabled = composerState?.draft?.quoteDisabled ?? false; 450 + 451 + await showModalBottomSheet<void>( 452 + context: context, 453 + isScrollControlled: true, 454 + showDragHandle: true, 455 + builder: (context) => ThreadingSettingsSheet( 456 + threadGateType: currentThreadGate, 457 + quoteDisabled: currentQuoteDisabled, 458 + onThreadGateChanged: (type) { 459 + ref.read(composerProvider(_args).notifier).setThreadGate(type); 460 + }, 461 + onQuoteDisabledChanged: (disabled) { 462 + ref.read(composerProvider(_args).notifier).setQuoteDisabled(disabled); 463 + }, 464 + ), 465 + ); 466 + } 467 + 468 + IconData _getThreadingIcon(Draft? draft) { 469 + if (draft?.quoteDisabled == true) { 470 + return Icons.format_quote_rounded; 471 + } 472 + switch (draft?.threadGateType) { 473 + case ThreadGateType.mention: 474 + return Icons.alternate_email; 475 + case ThreadGateType.following: 476 + return Icons.people_alt; 477 + case ThreadGateType.mentionAndFollowing: 478 + return Icons.group_work; 479 + case null: 480 + return Icons.lock_open; 481 + } 482 + } 483 + 484 + String _getThreadingLabel(Draft? draft) { 485 + if (draft?.quoteDisabled == true) { 486 + return 'No quotes'; 487 + } 488 + switch (draft?.threadGateType) { 489 + case ThreadGateType.mention: 490 + return 'Mentions only'; 491 + case ThreadGateType.following: 492 + return 'Following only'; 493 + case ThreadGateType.mentionAndFollowing: 494 + return 'Limited replies'; 495 + case null: 496 + return 'Post settings'; 497 + } 498 + } 499 + 398 500 @override 399 501 Widget build(BuildContext context) { 400 502 final composerAsync = ref.watch(composerProvider(_args)); ··· 506 608 ), 507 609 ) 508 610 : const SizedBox.shrink(), 611 + ), 612 + ], 613 + ), 614 + ), 615 + const SizedBox(height: 12), 616 + Padding( 617 + padding: const EdgeInsets.symmetric(horizontal: 16), 618 + child: Wrap( 619 + spacing: 8, 620 + runSpacing: 8, 621 + crossAxisAlignment: WrapCrossAlignment.center, 622 + children: [ 623 + if (state.draft?.langs.isNotEmpty ?? false) 624 + ...state.draft!.langs.map( 625 + (lang) => LanguagePill( 626 + code: lang, 627 + onRemove: () => ref 628 + .read(composerProvider(_args).notifier) 629 + .setLanguages( 630 + state.draft!.langs.where((l) => l != lang).toList(), 631 + ), 632 + ), 633 + ), 634 + InkWell( 635 + onTap: _openLanguageSelector, 636 + borderRadius: BorderRadius.circular(20), 637 + child: Container( 638 + height: 32, 639 + padding: const EdgeInsets.symmetric(horizontal: 12), 640 + decoration: BoxDecoration( 641 + border: Border.all(color: colorScheme.outlineVariant), 642 + borderRadius: BorderRadius.circular(20), 643 + ), 644 + child: Row( 645 + mainAxisSize: MainAxisSize.min, 646 + children: [ 647 + Icon( 648 + Icons.language, 649 + size: 16, 650 + color: colorScheme.onSurfaceVariant, 651 + ), 652 + const SizedBox(width: 6), 653 + Text( 654 + state.draft?.langs.isEmpty ?? true 655 + ? 'Add language' 656 + : 'Add', 657 + style: theme.textTheme.labelSmall?.copyWith( 658 + color: colorScheme.onSurfaceVariant, 659 + ), 660 + ), 661 + ], 662 + ), 663 + ), 664 + ), 665 + ContentWarningButton( 666 + labels: state.draft?.labels ?? [], 667 + onTap: _openContentWarningSelector, 668 + ), 669 + InkWell( 670 + onTap: _openThreadingSettings, 671 + borderRadius: BorderRadius.circular(20), 672 + child: Container( 673 + height: 32, 674 + padding: const EdgeInsets.symmetric(horizontal: 12), 675 + decoration: BoxDecoration( 676 + color: 677 + state.draft?.threadGateType != null || 678 + state.draft?.quoteDisabled == true 679 + ? colorScheme.secondaryContainer 680 + : colorScheme.surfaceContainerHighest, 681 + borderRadius: BorderRadius.circular(20), 682 + ), 683 + child: Row( 684 + mainAxisSize: MainAxisSize.min, 685 + children: [ 686 + Icon( 687 + _getThreadingIcon(state.draft), 688 + size: 16, 689 + color: 690 + state.draft?.threadGateType != null || 691 + state.draft?.quoteDisabled == true 692 + ? colorScheme.onSecondaryContainer 693 + : colorScheme.onSurfaceVariant, 694 + ), 695 + const SizedBox(width: 6), 696 + Text( 697 + _getThreadingLabel(state.draft), 698 + style: theme.textTheme.labelSmall?.copyWith( 699 + color: 700 + state.draft?.threadGateType != null || 701 + state.draft?.quoteDisabled == true 702 + ? colorScheme.onSecondaryContainer 703 + : colorScheme.onSurfaceVariant, 704 + ), 705 + ), 706 + ], 707 + ), 708 + ), 509 709 ), 510 710 ], 511 711 ),
+66
lib/src/features/composer/presentation/widgets/content_warning_button.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// A button that displays content warning state. 4 + /// 5 + /// Shows different states based on whether content warnings are set: 6 + /// - No warnings: Shows an icon with "Add warning" label 7 + /// - Has warnings: Shows an icon with the warning count 8 + class ContentWarningButton extends StatelessWidget { 9 + const ContentWarningButton({super.key, required this.labels, required this.onTap, this.size}); 10 + 11 + /// Currently selected content warning labels. 12 + final List<String> labels; 13 + 14 + /// Callback when the button is tapped. 15 + final VoidCallback onTap; 16 + 17 + /// Optional size constraint. 18 + final Size? size; 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + final theme = Theme.of(context); 23 + final hasWarnings = labels.isNotEmpty; 24 + final label = hasWarnings 25 + ? '${labels.length} warning${labels.length > 1 ? "s" : ""}' 26 + : 'Add warning'; 27 + 28 + return InkWell( 29 + onTap: onTap, 30 + borderRadius: BorderRadius.circular(20), 31 + child: Container( 32 + height: size?.height ?? 40, 33 + constraints: BoxConstraints(minWidth: size?.width ?? 100), 34 + padding: const EdgeInsets.symmetric(horizontal: 16), 35 + decoration: BoxDecoration( 36 + color: hasWarnings 37 + ? theme.colorScheme.errorContainer 38 + : theme.colorScheme.surfaceContainerHighest, 39 + borderRadius: BorderRadius.circular(20), 40 + ), 41 + child: Row( 42 + mainAxisSize: MainAxisSize.min, 43 + children: [ 44 + Icon( 45 + Icons.warning_amber_rounded, 46 + size: 18, 47 + color: hasWarnings 48 + ? theme.colorScheme.onErrorContainer 49 + : theme.colorScheme.onSurfaceVariant, 50 + ), 51 + const SizedBox(width: 8), 52 + Text( 53 + label, 54 + style: theme.textTheme.labelMedium?.copyWith( 55 + color: hasWarnings 56 + ? theme.colorScheme.onErrorContainer 57 + : theme.colorScheme.onSurfaceVariant, 58 + fontWeight: hasWarnings ? FontWeight.w600 : FontWeight.normal, 59 + ), 60 + ), 61 + ], 62 + ), 63 + ), 64 + ); 65 + } 66 + }
+124
lib/src/features/composer/presentation/widgets/content_warning_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// A bottom sheet for selecting content warning labels. 4 + /// 5 + /// Allows users to self-label their content with content warnings. 6 + class ContentWarningSheet extends StatefulWidget { 7 + const ContentWarningSheet({ 8 + super.key, 9 + required this.selectedLabels, 10 + required this.onSelectionChanged, 11 + }); 12 + 13 + /// Currently selected content warning labels. 14 + final List<String> selectedLabels; 15 + 16 + /// Callback when selection changes. 17 + final ValueChanged<List<String>> onSelectionChanged; 18 + 19 + @override 20 + State<ContentWarningSheet> createState() => _ContentWarningSheetState(); 21 + } 22 + 23 + class _ContentWarningSheetState extends State<ContentWarningSheet> { 24 + void _toggleLabel(String label) { 25 + final newSelection = List<String>.from(widget.selectedLabels); 26 + if (newSelection.contains(label)) { 27 + newSelection.remove(label); 28 + } else { 29 + newSelection.add(label); 30 + } 31 + widget.onSelectionChanged(newSelection); 32 + } 33 + 34 + @override 35 + Widget build(BuildContext context) { 36 + final theme = Theme.of(context); 37 + 38 + return SafeArea( 39 + child: Padding( 40 + padding: EdgeInsets.only( 41 + left: 16, 42 + right: 16, 43 + top: 16, 44 + bottom: MediaQuery.of(context).viewInsets.bottom + 16, 45 + ), 46 + child: Column( 47 + mainAxisSize: MainAxisSize.min, 48 + crossAxisAlignment: CrossAxisAlignment.stretch, 49 + children: [ 50 + Row( 51 + children: [ 52 + const Expanded( 53 + child: Text( 54 + 'Content Warnings', 55 + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), 56 + ), 57 + ), 58 + TextButton( 59 + onPressed: () { 60 + widget.onSelectionChanged([]); 61 + Navigator.of(context).pop(); 62 + }, 63 + child: const Text('Clear'), 64 + ), 65 + ], 66 + ), 67 + const SizedBox(height: 8), 68 + Text( 69 + 'Add content warnings to help others understand what to expect.', 70 + style: theme.textTheme.bodySmall?.copyWith( 71 + color: theme.colorScheme.onSurfaceVariant, 72 + ), 73 + ), 74 + const SizedBox(height: 16), 75 + ..._warningOptions.map((option) { 76 + final isSelected = widget.selectedLabels.contains(option.value); 77 + return CheckboxListTile( 78 + title: Text(option.label), 79 + subtitle: Text(option.description), 80 + value: isSelected, 81 + onChanged: (_) => _toggleLabel(option.value), 82 + contentPadding: const EdgeInsets.symmetric(horizontal: 0), 83 + ); 84 + }), 85 + const SizedBox(height: 16), 86 + FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done')), 87 + ], 88 + ), 89 + ), 90 + ); 91 + } 92 + } 93 + 94 + class _WarningOption { 95 + const _WarningOption({required this.value, required this.label, required this.description}); 96 + 97 + final String value; 98 + final String label; 99 + final String description; 100 + } 101 + 102 + /// Available content warning options. 103 + const List<_WarningOption> _warningOptions = [ 104 + _WarningOption( 105 + value: 'sexual', 106 + label: 'Sexual Content', 107 + description: 'Contains sexually suggestive or explicit content', 108 + ), 109 + _WarningOption( 110 + value: 'nudity', 111 + label: 'Nudity', 112 + description: 'Contains nude or partially nude figures', 113 + ), 114 + _WarningOption( 115 + value: 'porn', 116 + label: 'Pornography', 117 + description: 'Contains sexually explicit material', 118 + ), 119 + _WarningOption( 120 + value: 'graphic-media', 121 + label: 'Graphic Media', 122 + description: 'Contains graphic or violent imagery', 123 + ), 124 + ];
+50
lib/src/features/composer/presentation/widgets/language_pill.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// A compact pill widget displaying a language code. 4 + /// 5 + /// Shows ISO 639 language codes (e.g., "EN", "ES", "JA") as rounded pills. 6 + /// Can be displayed with or without a remove button. 7 + class LanguagePill extends StatelessWidget { 8 + const LanguagePill({super.key, required this.code, this.onRemove, this.showRemove = true}); 9 + 10 + /// The ISO 639 language code to display (will be uppercased). 11 + final String code; 12 + 13 + /// Callback when the remove button is tapped. 14 + final VoidCallback? onRemove; 15 + 16 + /// Whether to show the remove button. 17 + final bool showRemove; 18 + 19 + @override 20 + Widget build(BuildContext context) { 21 + final theme = Theme.of(context); 22 + 23 + return Container( 24 + decoration: BoxDecoration( 25 + color: theme.colorScheme.surfaceContainerHighest, 26 + borderRadius: BorderRadius.circular(16), 27 + ), 28 + child: InkWell( 29 + onTap: onRemove, 30 + borderRadius: BorderRadius.circular(16), 31 + child: Padding( 32 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 33 + child: Row( 34 + mainAxisSize: MainAxisSize.min, 35 + children: [ 36 + Text( 37 + code.toUpperCase(), 38 + style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600), 39 + ), 40 + if (showRemove && onRemove != null) ...[ 41 + const SizedBox(width: 4), 42 + Icon(Icons.close, size: 16, color: theme.colorScheme.onSurfaceVariant), 43 + ], 44 + ], 45 + ), 46 + ), 47 + ), 48 + ); 49 + } 50 + }
+372
lib/src/features/composer/presentation/widgets/language_selector_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + /// A bottom sheet for selecting languages for a post. 4 + /// 5 + /// Supports multi-selection with a maximum of 3 languages. 6 + /// Shows common languages as quick-select options and a search field. 7 + class LanguageSelectorSheet extends StatefulWidget { 8 + const LanguageSelectorSheet({ 9 + super.key, 10 + required this.selectedLanguages, 11 + required this.onSelectionChanged, 12 + }); 13 + 14 + /// Currently selected language codes. 15 + final List<String> selectedLanguages; 16 + 17 + /// Callback when selection changes. 18 + final ValueChanged<List<String>> onSelectionChanged; 19 + 20 + @override 21 + State<LanguageSelectorSheet> createState() => _LanguageSelectorSheetState(); 22 + } 23 + 24 + class _LanguageSelectorSheetState extends State<LanguageSelectorSheet> { 25 + static const int _maxLanguages = 3; 26 + late final TextEditingController _searchController; 27 + final List<Language> _filteredLanguages = List.from(_commonLanguages); 28 + 29 + @override 30 + void initState() { 31 + super.initState(); 32 + _searchController = TextEditingController()..addListener(_onSearchChanged); 33 + } 34 + 35 + @override 36 + void dispose() { 37 + _searchController.removeListener(_onSearchChanged); 38 + _searchController.dispose(); 39 + super.dispose(); 40 + } 41 + 42 + void _onSearchChanged() { 43 + setState(() { 44 + final query = _searchController.text.toLowerCase(); 45 + if (query.isEmpty) { 46 + _filteredLanguages.clear(); 47 + _filteredLanguages.addAll(_commonLanguages); 48 + } else { 49 + _filteredLanguages.clear(); 50 + _filteredLanguages.addAll( 51 + _allLanguages.where( 52 + (lang) => 53 + lang.name.toLowerCase().contains(query) || lang.code.toLowerCase().contains(query), 54 + ), 55 + ); 56 + } 57 + }); 58 + } 59 + 60 + void _toggleLanguage(String code) { 61 + final newSelection = List<String>.from(widget.selectedLanguages); 62 + if (newSelection.contains(code)) { 63 + newSelection.remove(code); 64 + } else if (newSelection.length < _maxLanguages) { 65 + newSelection.add(code); 66 + } 67 + widget.onSelectionChanged(newSelection); 68 + } 69 + 70 + @override 71 + Widget build(BuildContext context) { 72 + final theme = Theme.of(context); 73 + 74 + return PopScope( 75 + onPopInvokedWithResult: (didPop, _) { 76 + if (didPop) { 77 + widget.onSelectionChanged(widget.selectedLanguages); 78 + } 79 + }, 80 + child: SafeArea( 81 + child: Padding( 82 + padding: EdgeInsets.only( 83 + left: 16, 84 + right: 16, 85 + top: 16, 86 + bottom: MediaQuery.of(context).viewInsets.bottom + 16, 87 + ), 88 + child: Column( 89 + mainAxisSize: MainAxisSize.min, 90 + crossAxisAlignment: CrossAxisAlignment.stretch, 91 + children: [ 92 + Row( 93 + children: [ 94 + const Expanded( 95 + child: Text( 96 + 'Select Languages', 97 + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), 98 + ), 99 + ), 100 + TextButton( 101 + onPressed: () { 102 + widget.onSelectionChanged([]); 103 + Navigator.of(context).pop(); 104 + }, 105 + child: const Text('Clear'), 106 + ), 107 + ], 108 + ), 109 + const SizedBox(height: 8), 110 + if (widget.selectedLanguages.isNotEmpty) ...[ 111 + Wrap( 112 + spacing: 8, 113 + runSpacing: 8, 114 + children: widget.selectedLanguages.map((code) { 115 + return Chip( 116 + label: Text(code.toUpperCase()), 117 + onDeleted: () => _toggleLanguage(code), 118 + deleteIconColor: theme.colorScheme.onSurfaceVariant, 119 + ); 120 + }).toList(), 121 + ), 122 + const SizedBox(height: 16), 123 + ], 124 + TextField( 125 + controller: _searchController, 126 + decoration: const InputDecoration( 127 + hintText: 'Search languages...', 128 + prefixIcon: Icon(Icons.search), 129 + ), 130 + ), 131 + const SizedBox(height: 16), 132 + SizedBox( 133 + height: 300, 134 + child: ListView.builder( 135 + itemCount: _filteredLanguages.length, 136 + itemBuilder: (context, index) { 137 + final lang = _filteredLanguages[index]; 138 + final isSelected = widget.selectedLanguages.contains(lang.code); 139 + final isMaxReached = widget.selectedLanguages.length >= _maxLanguages; 140 + 141 + return CheckboxListTile( 142 + title: Text(lang.name), 143 + subtitle: Text(lang.code.toUpperCase()), 144 + value: isSelected, 145 + enabled: isSelected || !isMaxReached, 146 + onChanged: (value) => _toggleLanguage(lang.code), 147 + ); 148 + }, 149 + ), 150 + ), 151 + FilledButton( 152 + onPressed: () => Navigator.of(context).pop(), 153 + child: const Text('Done'), 154 + ), 155 + ], 156 + ), 157 + ), 158 + ), 159 + ); 160 + } 161 + } 162 + 163 + class Language { 164 + const Language({required this.code, required this.name}); 165 + 166 + final String code; 167 + final String name; 168 + } 169 + 170 + /// Common languages shown by default. 171 + const List<Language> _commonLanguages = [ 172 + Language(code: 'en', name: 'English'), 173 + Language(code: 'es', name: 'Spanish'), 174 + Language(code: 'fr', name: 'French'), 175 + Language(code: 'de', name: 'German'), 176 + Language(code: 'it', name: 'Italian'), 177 + Language(code: 'pt', name: 'Portuguese'), 178 + Language(code: 'ja', name: 'Japanese'), 179 + Language(code: 'ko', name: 'Korean'), 180 + Language(code: 'zh', name: 'Chinese'), 181 + Language(code: 'ar', name: 'Arabic'), 182 + Language(code: 'hi', name: 'Hindi'), 183 + Language(code: 'ru', name: 'Russian'), 184 + ]; 185 + 186 + /// All supported languages for search. 187 + const List<Language> _allLanguages = [ 188 + Language(code: 'aa', name: 'Afar'), 189 + Language(code: 'ab', name: 'Abkhazian'), 190 + Language(code: 'af', name: 'Afrikaans'), 191 + Language(code: 'ak', name: 'Akan'), 192 + Language(code: 'sq', name: 'Albanian'), 193 + Language(code: 'am', name: 'Amharic'), 194 + Language(code: 'ar', name: 'Arabic'), 195 + Language(code: 'an', name: 'Aragonese'), 196 + Language(code: 'hy', name: 'Armenian'), 197 + Language(code: 'as', name: 'Assamese'), 198 + Language(code: 'av', name: 'Avaric'), 199 + Language(code: 'ae', name: 'Avestan'), 200 + Language(code: 'ay', name: 'Aymara'), 201 + Language(code: 'az', name: 'Azerbaijani'), 202 + Language(code: 'ba', name: 'Bashkir'), 203 + Language(code: 'bm', name: 'Bambara'), 204 + Language(code: 'eu', name: 'Basque'), 205 + Language(code: 'be', name: 'Belarusian'), 206 + Language(code: 'bn', name: 'Bengali'), 207 + Language(code: 'bh', name: 'Bihari'), 208 + Language(code: 'bi', name: 'Bislama'), 209 + Language(code: 'bs', name: 'Bosnian'), 210 + Language(code: 'br', name: 'Breton'), 211 + Language(code: 'bg', name: 'Bulgarian'), 212 + Language(code: 'my', name: 'Burmese'), 213 + Language(code: 'ca', name: 'Catalan'), 214 + Language(code: 'ch', name: 'Chamorro'), 215 + Language(code: 'ce', name: 'Chechen'), 216 + Language(code: 'ny', name: 'Chichewa'), 217 + Language(code: 'zh', name: 'Chinese'), 218 + Language(code: 'cv', name: 'Chuvash'), 219 + Language(code: 'kw', name: 'Cornish'), 220 + Language(code: 'co', name: 'Corsican'), 221 + Language(code: 'cr', name: 'Cree'), 222 + Language(code: 'cs', name: 'Czech'), 223 + Language(code: 'da', name: 'Danish'), 224 + Language(code: 'dv', name: 'Dhivehi'), 225 + Language(code: 'nl', name: 'Dutch'), 226 + Language(code: 'dz', name: 'Dzongkha'), 227 + Language(code: 'en', name: 'English'), 228 + Language(code: 'eo', name: 'Esperanto'), 229 + Language(code: 'et', name: 'Estonian'), 230 + Language(code: 'ee', name: 'Ewe'), 231 + Language(code: 'fo', name: 'Faroese'), 232 + Language(code: 'fj', name: 'Fijian'), 233 + Language(code: 'fi', name: 'Finnish'), 234 + Language(code: 'fr', name: 'French'), 235 + Language(code: 'fy', name: 'Frisian'), 236 + Language(code: 'ff', name: 'Fulah'), 237 + Language(code: 'ka', name: 'Georgian'), 238 + Language(code: 'de', name: 'German'), 239 + Language(code: 'gd', name: 'Gaelic'), 240 + Language(code: 'ga', name: 'Irish'), 241 + Language(code: 'gl', name: 'Galician'), 242 + Language(code: 'gv', name: 'Manx'), 243 + Language(code: 'el', name: 'Greek'), 244 + Language(code: 'gn', name: 'Guarani'), 245 + Language(code: 'gu', name: 'Gujarati'), 246 + Language(code: 'ht', name: 'Haitian'), 247 + Language(code: 'ha', name: 'Hausa'), 248 + Language(code: 'he', name: 'Hebrew'), 249 + Language(code: 'hz', name: 'Herero'), 250 + Language(code: 'hi', name: 'Hindi'), 251 + Language(code: 'ho', name: 'Hiri Motu'), 252 + Language(code: 'hr', name: 'Croatian'), 253 + Language(code: 'hu', name: 'Hungarian'), 254 + Language(code: 'ig', name: 'Igbo'), 255 + Language(code: 'is', name: 'Icelandic'), 256 + Language(code: 'io', name: 'Ido'), 257 + Language(code: 'ii', name: 'Sichuan Yi'), 258 + Language(code: 'iu', name: 'Inuktitut'), 259 + Language(code: 'ie', name: 'Interlingue'), 260 + Language(code: 'ia', name: 'Interlingua'), 261 + Language(code: 'id', name: 'Indonesian'), 262 + Language(code: 'ik', name: 'Inupiaq'), 263 + Language(code: 'it', name: 'Italian'), 264 + Language(code: 'jv', name: 'Javanese'), 265 + Language(code: 'ja', name: 'Japanese'), 266 + Language(code: 'kl', name: 'Kalaallisut'), 267 + Language(code: 'kn', name: 'Kannada'), 268 + Language(code: 'ks', name: 'Kashmiri'), 269 + Language(code: 'kr', name: 'Kanuri'), 270 + Language(code: 'kk', name: 'Kazakh'), 271 + Language(code: 'km', name: 'Khmer'), 272 + Language(code: 'ki', name: 'Kikuyu'), 273 + Language(code: 'rw', name: 'Kinyarwanda'), 274 + Language(code: 'ky', name: 'Kyrgyz'), 275 + Language(code: 'kv', name: 'Komi'), 276 + Language(code: 'kg', name: 'Kongo'), 277 + Language(code: 'ko', name: 'Korean'), 278 + Language(code: 'kj', name: 'Kuanyama'), 279 + Language(code: 'ku', name: 'Kurdish'), 280 + Language(code: 'lo', name: 'Lao'), 281 + Language(code: 'la', name: 'Latin'), 282 + Language(code: 'lv', name: 'Latvian'), 283 + Language(code: 'li', name: 'Limburgish'), 284 + Language(code: 'ln', name: 'Lingala'), 285 + Language(code: 'lt', name: 'Lithuanian'), 286 + Language(code: 'lb', name: 'Luxembourgish'), 287 + Language(code: 'lu', name: 'Luba-Katanga'), 288 + Language(code: 'lg', name: 'Luganda'), 289 + Language(code: 'mk', name: 'Macedonian'), 290 + Language(code: 'mh', name: 'Marshallese'), 291 + Language(code: 'ml', name: 'Malayalam'), 292 + Language(code: 'mi', name: 'Maori'), 293 + Language(code: 'mr', name: 'Marathi'), 294 + Language(code: 'ms', name: 'Malay'), 295 + Language(code: 'mg', name: 'Malagasy'), 296 + Language(code: 'mt', name: 'Maltese'), 297 + Language(code: 'mn', name: 'Mongolian'), 298 + Language(code: 'na', name: 'Nauru'), 299 + Language(code: 'nv', name: 'Navajo'), 300 + Language(code: 'nr', name: 'Ndebele'), 301 + Language(code: 'nd', name: 'Ndebele'), 302 + Language(code: 'ng', name: 'Ndonga'), 303 + Language(code: 'ne', name: 'Nepali'), 304 + Language(code: 'nn', name: 'Norwegian'), 305 + Language(code: 'nb', name: 'Norwegian'), 306 + Language(code: 'no', name: 'Norwegian'), 307 + Language(code: 'ny', name: 'Chichewa'), 308 + Language(code: 'oc', name: 'Occitan'), 309 + Language(code: 'oj', name: 'Ojibwa'), 310 + Language(code: 'or', name: 'Oriya'), 311 + Language(code: 'om', name: 'Oromo'), 312 + Language(code: 'os', name: 'Ossetian'), 313 + Language(code: 'pa', name: 'Panjabi'), 314 + Language(code: 'fa', name: 'Persian'), 315 + Language(code: 'pi', name: 'Pali'), 316 + Language(code: 'pl', name: 'Polish'), 317 + Language(code: 'pt', name: 'Portuguese'), 318 + Language(code: 'ps', name: 'Pushto'), 319 + Language(code: 'qu', name: 'Quechua'), 320 + Language(code: 'ro', name: 'Romanian'), 321 + Language(code: 'rm', name: 'Romansh'), 322 + Language(code: 'rn', name: 'Rundi'), 323 + Language(code: 'ru', name: 'Russian'), 324 + Language(code: 'sg', name: 'Sango'), 325 + Language(code: 'sa', name: 'Sanskrit'), 326 + Language(code: 'si', name: 'Sinhala'), 327 + Language(code: 'sk', name: 'Slovak'), 328 + Language(code: 'sl', name: 'Slovenian'), 329 + Language(code: 'se', name: 'Northern Sami'), 330 + Language(code: 'sm', name: 'Samoan'), 331 + Language(code: 'sn', name: 'Shona'), 332 + Language(code: 'sd', name: 'Sindhi'), 333 + Language(code: 'so', name: 'Somali'), 334 + Language(code: 'st', name: 'Sotho'), 335 + Language(code: 'es', name: 'Spanish'), 336 + Language(code: 'sc', name: 'Sardinian'), 337 + Language(code: 'sr', name: 'Serbian'), 338 + Language(code: 'ss', name: 'Swati'), 339 + Language(code: 'su', name: 'Sundanese'), 340 + Language(code: 'sw', name: 'Swahili'), 341 + Language(code: 'sv', name: 'Swedish'), 342 + Language(code: 'ty', name: 'Tahitian'), 343 + Language(code: 'ta', name: 'Tamil'), 344 + Language(code: 'tt', name: 'Tatar'), 345 + Language(code: 'te', name: 'Telugu'), 346 + Language(code: 'tg', name: 'Tajik'), 347 + Language(code: 'tl', name: 'Tagalog'), 348 + Language(code: 'th', name: 'Thai'), 349 + Language(code: 'bo', name: 'Tibetan'), 350 + Language(code: 'ti', name: 'Tigrinya'), 351 + Language(code: 'to', name: 'Tonga'), 352 + Language(code: 'tn', name: 'Tswana'), 353 + Language(code: 'ts', name: 'Tsonga'), 354 + Language(code: 'tk', name: 'Turkmen'), 355 + Language(code: 'tr', name: 'Turkish'), 356 + Language(code: 'tw', name: 'Twi'), 357 + Language(code: 'ug', name: 'Uighur'), 358 + Language(code: 'uk', name: 'Ukrainian'), 359 + Language(code: 'ur', name: 'Urdu'), 360 + Language(code: 'uz', name: 'Uzbek'), 361 + Language(code: 've', name: 'Venda'), 362 + Language(code: 'vi', name: 'Vietnamese'), 363 + Language(code: 'vo', name: 'Volapuk'), 364 + Language(code: 'cy', name: 'Welsh'), 365 + Language(code: 'wa', name: 'Walloon'), 366 + Language(code: 'wo', name: 'Wolof'), 367 + Language(code: 'xh', name: 'Xhosa'), 368 + Language(code: 'yi', name: 'Yiddish'), 369 + Language(code: 'yo', name: 'Yoruba'), 370 + Language(code: 'za', name: 'Zhuang'), 371 + Language(code: 'zu', name: 'Zulu'), 372 + ];
+120
lib/src/features/composer/presentation/widgets/threading_settings_sheet.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/src/features/composer/domain/draft.dart'; 3 + 4 + /// A bottom sheet for configuring threading settings. 5 + /// 6 + /// Allows users to set: 7 + /// - Reply restrictions (thread gate type) 8 + /// - Quote post toggle 9 + class ThreadingSettingsSheet extends StatefulWidget { 10 + const ThreadingSettingsSheet({ 11 + super.key, 12 + required this.threadGateType, 13 + required this.quoteDisabled, 14 + required this.onThreadGateChanged, 15 + required this.onQuoteDisabledChanged, 16 + }); 17 + 18 + /// Current thread gate type (null = no restriction). 19 + final ThreadGateType? threadGateType; 20 + 21 + /// Current quote disabled state. 22 + final bool quoteDisabled; 23 + 24 + /// Callback when thread gate type changes. 25 + final ValueChanged<ThreadGateType?> onThreadGateChanged; 26 + 27 + /// Callback when quote disabled changes. 28 + final ValueChanged<bool> onQuoteDisabledChanged; 29 + 30 + @override 31 + State<ThreadingSettingsSheet> createState() => _ThreadingSettingsSheetState(); 32 + } 33 + 34 + class _ThreadingSettingsSheetState extends State<ThreadingSettingsSheet> { 35 + @override 36 + Widget build(BuildContext context) { 37 + final theme = Theme.of(context); 38 + 39 + return SafeArea( 40 + child: Padding( 41 + padding: EdgeInsets.only( 42 + left: 16, 43 + right: 16, 44 + top: 16, 45 + bottom: MediaQuery.of(context).viewInsets.bottom + 16, 46 + ), 47 + child: Column( 48 + mainAxisSize: MainAxisSize.min, 49 + crossAxisAlignment: CrossAxisAlignment.stretch, 50 + children: [ 51 + const Text( 52 + 'Post Settings', 53 + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), 54 + ), 55 + const SizedBox(height: 16), 56 + Text( 57 + 'Who can reply', 58 + style: theme.textTheme.titleSmall?.copyWith( 59 + color: theme.colorScheme.onSurfaceVariant, 60 + fontWeight: FontWeight.w600, 61 + ), 62 + ), 63 + const SizedBox(height: 8), 64 + RadioGroup<ThreadGateType?>( 65 + groupValue: widget.threadGateType, 66 + onChanged: (value) => widget.onThreadGateChanged(value), 67 + child: const Column( 68 + mainAxisSize: MainAxisSize.min, 69 + children: [ 70 + RadioListTile<ThreadGateType?>( 71 + title: Text('Everyone'), 72 + subtitle: Text('Anyone can reply to this post'), 73 + value: null, 74 + contentPadding: EdgeInsets.symmetric(horizontal: 0), 75 + ), 76 + RadioListTile<ThreadGateType?>( 77 + title: Text('People you mention'), 78 + subtitle: Text('Only users you @mention can reply'), 79 + value: ThreadGateType.mention, 80 + contentPadding: EdgeInsets.symmetric(horizontal: 0), 81 + ), 82 + RadioListTile<ThreadGateType?>( 83 + title: Text('People you follow'), 84 + subtitle: Text('Only users you follow can reply'), 85 + value: ThreadGateType.following, 86 + contentPadding: EdgeInsets.symmetric(horizontal: 0), 87 + ), 88 + RadioListTile<ThreadGateType?>( 89 + title: Text('Mentioned & Following'), 90 + subtitle: Text('Users you mention or follow can reply'), 91 + value: ThreadGateType.mentionAndFollowing, 92 + contentPadding: EdgeInsets.symmetric(horizontal: 0), 93 + ), 94 + ], 95 + ), 96 + ), 97 + const SizedBox(height: 16), 98 + Text( 99 + 'Quote posts', 100 + style: theme.textTheme.titleSmall?.copyWith( 101 + color: theme.colorScheme.onSurfaceVariant, 102 + fontWeight: FontWeight.w600, 103 + ), 104 + ), 105 + const SizedBox(height: 8), 106 + SwitchListTile( 107 + title: const Text('Disable quote posts'), 108 + subtitle: const Text('Prevent others from quoting this post'), 109 + value: widget.quoteDisabled, 110 + onChanged: widget.onQuoteDisabledChanged, 111 + contentPadding: const EdgeInsets.symmetric(horizontal: 0), 112 + ), 113 + const SizedBox(height: 16), 114 + FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done')), 115 + ], 116 + ), 117 + ), 118 + ); 119 + } 120 + }