diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index be2024f96..7e5cce832 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2139,6 +2139,14 @@ "@postCreatedSuccessfully": { "description": "Notifying the user that their post was created successfully" }, + "postFlairs": "Flairs", + "@postFlairs": { + "description": "Label for post flair selection" + }, + "postFlairsUnavailable": "No flair options available for this community", + "@postFlairsUnavailable": { + "description": "Helper text when no post flairs are available" + }, "postLocked": "Post locked. No replies allowed.", "@postLocked": {}, "postMetadataInstructions": "You can customize the metadata information by dragging and dropping the desired information", @@ -2163,6 +2171,14 @@ }, "postSwipeGesturesHint": "Looking to use buttons instead? Change what buttons appear on post cards in general settings.", "@postSwipeGesturesHint": {}, + "postTags": "Tags", + "@postTags": { + "description": "Label for post tags" + }, + "postTagsHelperText": "Separate tags with commas", + "@postTagsHelperText": { + "description": "Helper text for comma-separated post tags" + }, "postTitle": "Title", "@postTitle": {}, "postTitleFontScale": "Post Title Font Scale", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 65c1da3f0..8958575a3 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -3366,6 +3366,18 @@ abstract class AppLocalizations { /// **'Post created successfully!'** String get postCreatedSuccessfully; + /// Label for post flair selection + /// + /// In en, this message translates to: + /// **'Flairs'** + String get postFlairs; + + /// Helper text when no post flairs are available + /// + /// In en, this message translates to: + /// **'No flair options available for this community'** + String get postFlairsUnavailable; + /// No description provided for @postLocked. /// /// In en, this message translates to: @@ -3414,6 +3426,18 @@ abstract class AppLocalizations { /// **'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'** String get postSwipeGesturesHint; + /// Label for post tags + /// + /// In en, this message translates to: + /// **'Tags'** + String get postTags; + + /// Helper text for comma-separated post tags + /// + /// In en, this message translates to: + /// **'Separate tags with commas'** + String get postTagsHelperText; + /// No description provided for @postTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index a6d5afd0d..cb190ae3c 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -1839,6 +1839,13 @@ class AppLocalizationsAr extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1866,6 +1873,12 @@ class AppLocalizationsAr extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_be.dart b/lib/l10n/generated/app_localizations_be.dart index 81f53abb3..a31af69ed 100644 --- a/lib/l10n/generated/app_localizations_be.dart +++ b/lib/l10n/generated/app_localizations_be.dart @@ -1847,6 +1847,13 @@ class AppLocalizationsBe extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1874,6 +1881,12 @@ class AppLocalizationsBe extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_cs.dart b/lib/l10n/generated/app_localizations_cs.dart index bbb479db7..53e4e481a 100644 --- a/lib/l10n/generated/app_localizations_cs.dart +++ b/lib/l10n/generated/app_localizations_cs.dart @@ -1853,6 +1853,13 @@ class AppLocalizationsCs extends AppLocalizations { @override String get postCreatedSuccessfully => 'Příspěvek úspěšně vytvořen!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Příspěvek zamčen. Odpovědi nejsou povoleny.'; @@ -1879,6 +1886,12 @@ class AppLocalizationsCs extends AppLocalizations { String get postSwipeGesturesHint => 'Chcete spíš používat tlačítka? Změňte, jaká tlačítka se zobrazují na příspěvkových kartách v základním nastavení.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Nadpis'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 527fa02b0..62f3e737a 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -1878,6 +1878,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post erfolgreich erstellt!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post gesperrt. Keine Antworten erlaubt.'; @@ -1905,6 +1912,12 @@ class AppLocalizationsDe extends AppLocalizations { String get postSwipeGesturesHint => 'Möchtest du stattdessen Buttons verwenden? Ändere in den allgemeinen Einstellungen, welche Buttons auf den Karten erscheinen.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Titel'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index f99aaee4f..9633437dc 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1847,6 +1847,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1874,6 +1881,12 @@ class AppLocalizationsEn extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_eo.dart b/lib/l10n/generated/app_localizations_eo.dart index d5fbed689..84e521580 100644 --- a/lib/l10n/generated/app_localizations_eo.dart +++ b/lib/l10n/generated/app_localizations_eo.dart @@ -1836,6 +1836,13 @@ class AppLocalizationsEo extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Afiŝo ŝlosita. Neniuj respondoj permesitaj.'; @@ -1863,6 +1870,12 @@ class AppLocalizationsEo extends AppLocalizations { String get postSwipeGesturesHint => 'Ĉu vi volas uzi butonojn anstataŭe? Ŝanĝu kiajn butonojn aperas sur afiŝkartoj en ĝeneralaj agordoj.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Titolo'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f5654a30c..d51f82ded 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1885,6 +1885,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get postCreatedSuccessfully => '¡Publicación creada exitosamente!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Tema bloqueado. No se permiten respuestas.'; @@ -1912,6 +1919,12 @@ class AppLocalizationsEs extends AppLocalizations { String get postSwipeGesturesHint => '¿Quieres utilizar los botones? Cambia los botones que aparecen en los temas en los ajustes generales.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Título'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index a13afdde7..c42938592 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -1839,6 +1839,13 @@ class AppLocalizationsFi extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Postaus lukittu. Vastauksia ei sallita.'; @@ -1866,6 +1873,12 @@ class AppLocalizationsFi extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Otsikko'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 9a5b5bcfb..d5a07aed2 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -1874,6 +1874,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Publication verrouillée. Impossible de répondre.'; @@ -1901,6 +1908,12 @@ class AppLocalizationsFr extends AppLocalizations { String get postSwipeGesturesHint => 'Vous souhaitez utiliser des boutons à la place ? Modifiez les boutons qui apparaissent dans les publications dans les paramètres généraux.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Titre'; diff --git a/lib/l10n/generated/app_localizations_hu.dart b/lib/l10n/generated/app_localizations_hu.dart index 8bed9040b..30e4daaeb 100644 --- a/lib/l10n/generated/app_localizations_hu.dart +++ b/lib/l10n/generated/app_localizations_hu.dart @@ -1847,6 +1847,13 @@ class AppLocalizationsHu extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1874,6 +1881,12 @@ class AppLocalizationsHu extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 67ec2996d..0c41ec886 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -1852,6 +1852,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post creato con successo!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Il post è bloccato. Non è permesso rispondere.'; @@ -1879,6 +1886,12 @@ class AppLocalizationsIt extends AppLocalizations { String get postSwipeGesturesHint => 'Preferisci usare i pulsanti? Cambia quali pulsanti compaiono nelle schermate dei post nelle impostazioni generali.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Titolo'; diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart index 1ec30ca1d..f3af3b144 100644 --- a/lib/l10n/generated/app_localizations_nb.dart +++ b/lib/l10n/generated/app_localizations_nb.dart @@ -1829,6 +1829,13 @@ class AppLocalizationsNb extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Innlegg låst. Svar tillates ikke.'; @@ -1856,6 +1863,12 @@ class AppLocalizationsNb extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Navn'; diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index 95dd3c876..a3f4c7231 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -1867,6 +1867,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get postCreatedSuccessfully => 'Bericht succesvol aangemaakt!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Bericht vergrendeld. Geen reacties toegestaan.'; @@ -1894,6 +1901,12 @@ class AppLocalizationsNl extends AppLocalizations { String get postSwipeGesturesHint => 'Wilt u liever knoppen gebruiken? Wijzig welke knoppen worden weergegeven op berichtkaarten in de algemene instellingen.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Titel'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 5990803dd..76b4beb6a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -1855,6 +1855,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get postCreatedSuccessfully => 'Utworzono wpis!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Wpis zablokowany. Nie można odpowiedzieć.'; @@ -1881,6 +1888,12 @@ class AppLocalizationsPl extends AppLocalizations { String get postSwipeGesturesHint => 'Wolisz używać przycisków? Zmień jakie przyciski będą widoczne na karcie wpisu w ustawieniach generalnych.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Tytuł'; diff --git a/lib/l10n/generated/app_localizations_ps.dart b/lib/l10n/generated/app_localizations_ps.dart index b7d6b4b94..cbaa0f7eb 100644 --- a/lib/l10n/generated/app_localizations_ps.dart +++ b/lib/l10n/generated/app_localizations_ps.dart @@ -1848,6 +1848,13 @@ class AppLocalizationsPs extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1875,6 +1882,12 @@ class AppLocalizationsPs extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 0c5ec0c05..0ac40ce30 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -1856,6 +1856,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1883,6 +1890,12 @@ class AppLocalizationsPt extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 70644e712..b8761a889 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -1845,6 +1845,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Пост заблокирован. Ответы не допускаются.'; @@ -1872,6 +1879,12 @@ class AppLocalizationsRu extends AppLocalizations { String get postSwipeGesturesHint => 'Хотите вместо этого использовать кнопки? Измените, какие кнопки отображаются на открытках, в общих настройках.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Название'; diff --git a/lib/l10n/generated/app_localizations_sk.dart b/lib/l10n/generated/app_localizations_sk.dart index a75655138..707120606 100644 --- a/lib/l10n/generated/app_localizations_sk.dart +++ b/lib/l10n/generated/app_localizations_sk.dart @@ -1856,6 +1856,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get postCreatedSuccessfully => 'Príspevok úspešne vytvorený!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. Nie sú povolené žiadne odpovede.'; @@ -1882,6 +1889,12 @@ class AppLocalizationsSk extends AppLocalizations { String get postSwipeGesturesHint => 'Chcete namiesto toho použiť tlačidlá? Vo všeobecných nastaveniach zmeňte, aké tlačidlá sa zobrazujú na kartách príspevkov.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Nadpis'; diff --git a/lib/l10n/generated/app_localizations_sv.dart b/lib/l10n/generated/app_localizations_sv.dart index 601209ecd..abbe9745e 100644 --- a/lib/l10n/generated/app_localizations_sv.dart +++ b/lib/l10n/generated/app_localizations_sv.dart @@ -1841,6 +1841,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1868,6 +1875,12 @@ class AppLocalizationsSv extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Tittel'; diff --git a/lib/l10n/generated/app_localizations_ta.dart b/lib/l10n/generated/app_localizations_ta.dart index 76ff0910e..accac44ee 100644 --- a/lib/l10n/generated/app_localizations_ta.dart +++ b/lib/l10n/generated/app_localizations_ta.dart @@ -1881,6 +1881,13 @@ class AppLocalizationsTa extends AppLocalizations { @override String get postCreatedSuccessfully => 'இடுகை வெற்றிகரமாக உருவாக்கப்பட்டது!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'இடுகை பூட்டப்பட்டுள்ளது. பதில்கள் எதுவும் அனுமதிக்கப்படவில்லை.'; @@ -1909,6 +1916,12 @@ class AppLocalizationsTa extends AppLocalizations { String get postSwipeGesturesHint => 'அதற்கு பதிலாக பொத்தான்களைப் பயன்படுத்த விரும்புகிறீர்களா? பொது அமைப்புகளில் தபால் அட்டைகளில் பொத்தான்கள் தோன்றுவதை மாற்றவும்.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'தலைப்பு'; diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index ce7354aa3..44aa6c0fb 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -1856,6 +1856,13 @@ class AppLocalizationsTr extends AppLocalizations { @override String get postCreatedSuccessfully => 'Gönderi başarıyla oluşturuldu!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Gönderi kilitli. Yanıtlara izin verilmiyor.'; @@ -1883,6 +1890,12 @@ class AppLocalizationsTr extends AppLocalizations { String get postSwipeGesturesHint => 'Bunun yerine düğmeleri mi kullanmak istiyorsunuz? Genel ayarlarda gönderi kartlarında hangi düğmelerin görüneceğini değiştirin.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Başlık'; diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart index 0dd3ac020..4f2a0be0a 100644 --- a/lib/l10n/generated/app_localizations_uk.dart +++ b/lib/l10n/generated/app_localizations_uk.dart @@ -1842,6 +1842,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1869,6 +1876,12 @@ class AppLocalizationsUk extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 470af03a7..af7158bdc 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -1847,6 +1847,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get postCreatedSuccessfully => 'Post created successfully!'; + @override + String get postFlairs => 'Flairs'; + + @override + String get postFlairsUnavailable => + 'No flair options available for this community'; + @override String get postLocked => 'Post locked. No replies allowed.'; @@ -1874,6 +1881,12 @@ class AppLocalizationsZh extends AppLocalizations { String get postSwipeGesturesHint => 'Looking to use buttons instead? Change what buttons appear on post cards in general settings.'; + @override + String get postTags => 'Tags'; + + @override + String get postTagsHelperText => 'Separate tags with commas'; + @override String get postTitle => 'Title'; diff --git a/lib/src/features/community/data/repositories/community_repository_impl.dart b/lib/src/features/community/data/repositories/community_repository_impl.dart index 3ba2a2aa6..81d8dfba5 100644 --- a/lib/src/features/community/data/repositories/community_repository_impl.dart +++ b/lib/src/features/community/data/repositories/community_repository_impl.dart @@ -60,6 +60,7 @@ class CommunityRepositoryImpl implements CommunityRepository { site: response.site, moderators: response.moderators, discussionLanguages: response.discussionLanguages, + flairs: response.flairs, ); } diff --git a/lib/src/features/community/domain/models/community_details.dart b/lib/src/features/community/domain/models/community_details.dart index 18eaca647..f06fa9414 100644 --- a/lib/src/features/community/domain/models/community_details.dart +++ b/lib/src/features/community/domain/models/community_details.dart @@ -1,15 +1,26 @@ import 'package:thunder/src/foundation/primitives/primitives.dart'; class CommunityDetails { + /// The community information final ThunderCommunity community; + + /// The site information, if available final ThunderSite? site; + + /// The list of moderators for the community final List moderators; + + /// The list of discussion languages available for the community final List discussionLanguages; + /// The list of flairs available for the community. PieFed only. + final List flairs; + const CommunityDetails({ required this.community, required this.site, required this.moderators, required this.discussionLanguages, + this.flairs = const [], }); } diff --git a/lib/src/features/community/presentation/widgets/post_card.dart b/lib/src/features/community/presentation/widgets/post_card.dart index 667702ee8..23ea2441e 100644 --- a/lib/src/features/community/presentation/widgets/post_card.dart +++ b/lib/src/features/community/presentation/widgets/post_card.dart @@ -198,6 +198,7 @@ class _PostCardState extends State { ); final hasFeedBloc = context.findAncestorWidgetOfExactType>() != null; final feedType = widget.feedType ?? (hasFeedBloc ? context.select((bloc) => bloc.state.feedType) : null); + final flairs = feedType == FeedType.community ? widget.post.flairs : const []; final postIsCompact = useCompactView || (pinnedPostsUseCompactView && (widget.post.featuredLocal || (feedType == FeedType.community && widget.post.featuredCommunity))) || (linkPostsUseCompactView && widget.post.media.isNotEmpty && widget.post.media.first.mediaType == MediaType.link); @@ -208,6 +209,7 @@ class _PostCardState extends State { post: widget.post, feedType: feedType, feedListType: widget.feedListType, + flairs: flairs, creator: widget.post.creator!, community: widget.post.community!, indicateRead: widget.indicateRead, @@ -222,6 +224,7 @@ class _PostCardState extends State { post: widget.post, feedType: feedType, feedListType: widget.feedListType, + flairs: flairs, hideThumbnails: hideThumbnails, hideNsfwPreviews: hideNsfwPreviews, markPostReadOnMediaView: markPostReadOnMediaView, diff --git a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart index 0a22d20e2..1b8e7f1bf 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_comfortable.dart @@ -25,6 +25,9 @@ class PostCardViewComfortable extends StatelessWidget { /// Optional feed list type override for contexts without a FeedBloc. final FeedListType? feedListType; + /// Flairs to render with the title. + final List flairs; + /// Whether to hide thumbnails. final bool hideThumbnails; @@ -81,6 +84,7 @@ class PostCardViewComfortable extends StatelessWidget { required this.post, this.feedType, this.feedListType, + this.flairs = const [], required this.hideThumbnails, required this.hideNsfwPreviews, required this.edgeToEdgeImages, @@ -232,6 +236,7 @@ class PostCardViewComfortable extends StatelessWidget { deleted: post.deleted, removed: post.removed, dim: dim, + flairs: flairs, ), ); diff --git a/lib/src/features/community/presentation/widgets/post_card_view_compact.dart b/lib/src/features/community/presentation/widgets/post_card_view_compact.dart index ef06e78b7..d8081d3ae 100644 --- a/lib/src/features/community/presentation/widgets/post_card_view_compact.dart +++ b/lib/src/features/community/presentation/widgets/post_card_view_compact.dart @@ -32,6 +32,9 @@ class PostCardViewCompact extends StatelessWidget { /// Optional feed list type override for contexts without a FeedBloc. final FeedListType? feedListType; + /// Flairs to render with the title. + final List flairs; + /// Determines whether the media thumbnails should be shown or not. final bool showMedia; @@ -47,6 +50,7 @@ class PostCardViewCompact extends StatelessWidget { this.indicateRead, this.feedType, this.feedListType, + this.flairs = const [], this.showMedia = true, required this.isLastTapped, }); @@ -115,6 +119,7 @@ class PostCardViewCompact extends StatelessWidget { deleted: post.deleted, removed: post.removed, dim: dim, + flairs: flairs, ), if (!showCommunityFirst) postCardAuthor, PostCardMetadata( diff --git a/lib/src/features/post/data/repositories/post_repository.dart b/lib/src/features/post/data/repositories/post_repository.dart index a80355a0f..88b91f8e6 100644 --- a/lib/src/features/post/data/repositories/post_repository.dart +++ b/lib/src/features/post/data/repositories/post_repository.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; -import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/foundation/networking/networking.dart'; import 'package:thunder/src/foundation/errors/errors.dart'; +import 'package:thunder/src/foundation/networking/networking.dart'; +import 'package:thunder/src/foundation/primitives/primitives.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -40,6 +40,8 @@ abstract class PostRepository { String? url, String? customThumbnail, String? altText, + List? tags, + List? flairIds, bool? nsfw, int? postIdBeingEdited, int? languageId, @@ -188,6 +190,8 @@ class PostRepositoryImpl implements PostRepository { String? url, String? customThumbnail, String? altText, + List? tags, + List? flairIds, bool? nsfw, int? postIdBeingEdited, int? languageId, @@ -198,24 +202,28 @@ class PostRepositoryImpl implements PostRepository { ThunderPost response; if (postIdBeingEdited != null) { - response = await _api.editPost( + response = await _api.editPostWithMetadata( postId: postIdBeingEdited, title: name, contents: body, url: url?.isEmpty == true ? null : url, customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, altText: altText?.isEmpty == true ? null : altText, + tags: tags, + flairIds: flairIds, nsfw: nsfw, languageId: languageId, ); } else { - response = await _api.createPost( + response = await _api.createPostWithMetadata( communityId: communityId, title: name, contents: body, url: url?.isEmpty == true ? null : url, customThumbnail: customThumbnail?.isEmpty == true ? null : customThumbnail, altText: altText?.isEmpty == true ? null : altText, + tags: tags, + flairIds: flairIds, nsfw: nsfw, languageId: languageId, ); diff --git a/lib/src/features/post/presentation/pages/create_post_page.dart b/lib/src/features/post/presentation/pages/create_post_page.dart index 9ecf3f0ad..3bf45d3a7 100644 --- a/lib/src/features/post/presentation/pages/create_post_page.dart +++ b/lib/src/features/post/presentation/pages/create_post_page.dart @@ -38,6 +38,15 @@ import 'package:thunder/src/features/instance/domain/utils/instance_link_utils.d import 'package:thunder/packages/ui/ui.dart' show showSnackbar; import 'package:thunder/src/shared/content/utils/media/media_utils.dart' show isImageUrl, selectImagesToUpload; +enum _PiefedMetadataStatus { + initial, + unsupported, + empty, + loading, + loaded, + error, +} + class CreatePostPage extends StatefulWidget { /// The account to use for composing this post. final Account? account; @@ -153,6 +162,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse final TextEditingController _urlTextController = TextEditingController(); final TextEditingController _customThumbnailTextController = TextEditingController(); final TextEditingController _altTextTextController = TextEditingController(); + final TextEditingController _tagsTextController = TextEditingController(); /// The focus node for the body. This is used to keep track of the position of the cursor when toggling preview final FocusNode _bodyFocusNode = FocusNode(); @@ -165,6 +175,11 @@ class _CreatePostPageState extends State with WidgetsBindingObse /// The id of the account for which the draft is being saved. This is used to determine which draft to restore when the page is opened. String? _draftAccountId; + _PiefedMetadataStatus _piefedMetadataStatus = _PiefedMetadataStatus.initial; + List _availablePiefedFlairs = const []; + List _selectedPiefedFlairIds = const []; + int _piefedMetadataRequestId = 0; + @override void initState() { super.initState(); @@ -180,7 +195,9 @@ class _CreatePostPageState extends State with WidgetsBindingObse communityId ??= widget.community?.id; } - unawaited(_restoreCommunity()); + _hydratePiefedMetadataFromPost(); + + unawaited(_refreshPiefedMetadata()); // Set up any text controller listeners _titleTextController.addListener(() { @@ -252,6 +269,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse _urlTextController.text = widget.post!.url ?? ''; _customThumbnailTextController.text = widget.post!.thumbnailUrl ?? ''; _altTextTextController.text = widget.post!.altText ?? ''; + _tagsTextController.text = encodePiefedTags(widget.post!.tags); _bodyTextController.text = widget.post!.body ?? ''; isNSFW = widget.post!.nsfw; languageId = widget.post!.languageId; @@ -280,6 +298,7 @@ class _CreatePostPageState extends State with WidgetsBindingObse _urlTextController.dispose(); _customThumbnailTextController.dispose(); _altTextTextController.dispose(); + _tagsTextController.dispose(); _bodyFocusNode.dispose(); super.dispose(); @@ -325,36 +344,99 @@ class _CreatePostPageState extends State with WidgetsBindingObse _urlTextController.text = widget.post?.url ?? ''; _customThumbnailTextController.text = widget.post?.thumbnailUrl ?? ''; _altTextTextController.text = widget.post?.altText ?? ''; + _tagsTextController.text = encodePiefedTags(widget.post?.tags); _bodyTextController.text = widget.post?.body ?? ''; setState(() { isNSFW = widget.post?.nsfw ?? false; languageId = widget.post?.languageId; }); + + _hydratePiefedMetadataFromPost(); }, ); } } - Future _restoreCommunity() async { + void _hydratePiefedMetadataFromPost() { + _availablePiefedFlairs = widget.post?.flairs ?? const []; + _selectedPiefedFlairIds = normalizePiefedFlairIds(widget.post?.flairs.map((flair) => flair.id)); + _piefedMetadataStatus = _availablePiefedFlairs.isEmpty ? _PiefedMetadataStatus.empty : _PiefedMetadataStatus.loaded; + } + + void _resetPiefedMetadata({ + required _PiefedMetadataStatus status, + bool clearSelection = true, + }) { + if (!mounted) { + _piefedMetadataStatus = status; + _availablePiefedFlairs = const []; + if (clearSelection) { + _selectedPiefedFlairIds = const []; + } + return; + } + + setState(() { + _piefedMetadataStatus = status; + _availablePiefedFlairs = const []; + if (clearSelection) { + _selectedPiefedFlairIds = const []; + } + }); + } + + Future _refreshPiefedMetadata() async { final account = context.read().state.effectiveAccount; - if (community != null || communityId == null) { + if (account.platform != ThreadiversePlatform.piefed) { + _resetPiefedMetadata(status: _PiefedMetadataStatus.unsupported); return; } + if (communityId == null) { + _resetPiefedMetadata(status: _PiefedMetadataStatus.empty); + return; + } + + final requestId = ++_piefedMetadataRequestId; + + if (mounted) { + setState(() { + _piefedMetadataStatus = _PiefedMetadataStatus.loading; + }); + } else { + _piefedMetadataStatus = _PiefedMetadataStatus.loading; + } + try { - final details = await CommunityRepositoryImpl(account: account).getCommunity(id: communityId); + final details = await CommunityRepositoryImpl(account: account).getCommunity( + id: communityId, + ); - if (!mounted) { + if (!mounted || requestId != _piefedMetadataRequestId || communityId != details.community.id) { return; } setState(() { community = details.community; + _availablePiefedFlairs = details.flairs; + _selectedPiefedFlairIds = retainValidPiefedFlairSelection( + selectedFlairIds: _selectedPiefedFlairIds, + availableFlairIds: details.flairs.map((flair) => flair.id), + clearWhenUnavailable: true, + ); + _piefedMetadataStatus = details.flairs.isEmpty ? _PiefedMetadataStatus.empty : _PiefedMetadataStatus.loaded; }); } catch (_) { - // It's fine to continue without the full community object. + if (!mounted || requestId != _piefedMetadataRequestId) { + return; + } + + setState(() { + _piefedMetadataStatus = _PiefedMetadataStatus.error; + _availablePiefedFlairs = const []; + }); } } @@ -375,6 +457,133 @@ class _CreatePostPageState extends State with WidgetsBindingObse body: _bodyTextController.text, ); + bool get _isPiefedComposer => context.read().state.effectiveAccount.platform == ThreadiversePlatform.piefed; + + List? _submissionTags() { + if (!_isPiefedComposer) { + return null; + } + + return resolveSubmittedPiefedTags( + _tagsTextController.text, + originalTags: widget.post?.tags, + ); + } + + List? _submissionFlairIds() { + if (!_isPiefedComposer) { + return null; + } + + return resolveSubmittedPiefedFlairIds( + _selectedPiefedFlairIds, + originalFlairIds: widget.post?.flairs.map((flair) => flair.id), + ); + } + + bool get _hasSelectablePiefedFlairs => _availablePiefedFlairs.isNotEmpty; + + String? get _piefedFlairHelperText => switch (_piefedMetadataStatus) { + _PiefedMetadataStatus.loading => GlobalContext.l10n.loading, + _PiefedMetadataStatus.empty || _PiefedMetadataStatus.error => GlobalContext.l10n.postFlairsUnavailable, + _ => null, + }; + + List get _selectedPiefedFlairs => _availablePiefedFlairs.where((flair) => _selectedPiefedFlairIds.contains(flair.id)).toList(); + + Widget? get _piefedFlairSuffixIcon => switch (_piefedMetadataStatus) { + _PiefedMetadataStatus.loading => const Padding( + padding: EdgeInsets.all(12.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + _ when _hasSelectablePiefedFlairs => const Icon(Icons.arrow_drop_down_rounded), + _ => null, + }; + + void _resetPiefedMetadataForContextChange() { + _resetPiefedMetadata( + status: _isPiefedComposer ? _PiefedMetadataStatus.empty : _PiefedMetadataStatus.unsupported, + ); + } + + Future _selectPostFlairs() async { + if (_availablePiefedFlairs.isEmpty) { + return; + } + + final selectedFlairIds = await showModalBottomSheet>( + context: context, + showDragHandle: true, + builder: (context) { + final workingSelection = _selectedPiefedFlairIds.toSet(); + + return StatefulBuilder( + builder: (context, setModalState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final flair in _availablePiefedFlairs) + CheckboxListTile( + value: workingSelection.contains(flair.id), + title: Text(flair.title), + onChanged: (selected) { + setModalState(() { + if (selected == true) { + workingSelection.add(flair.id); + } else { + workingSelection.remove(flair.id); + } + }); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(const []), + child: Text(MaterialLocalizations.of(context).clearButtonTooltip), + ), + const Spacer(), + FilledButton( + onPressed: () => Navigator.of(context).pop(workingSelection.toList()), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + + if (selectedFlairIds == null || !mounted) { + return; + } + + setState(() { + _selectedPiefedFlairIds = normalizePiefedFlairIds(selectedFlairIds); + }); + } + void _onDraftInputChanged() { _draftDebounceTimer?.cancel(); _draftDebounceTimer = Timer(const Duration(milliseconds: 800), _persistOrDeleteDraft); @@ -428,7 +637,9 @@ class _CreatePostPageState extends State with WidgetsBindingObse child: BlocConsumer( listener: (context, featureAccountState) { _draftAccountId = featureAccountState.effectiveAccount.id; + _resetPiefedMetadataForContextChange(); context.read().switchAccount(featureAccountState.effectiveAccount); + unawaited(_refreshPiefedMetadata()); }, builder: (context, featureAccountState) { final account = featureAccountState.effectiveAccount; @@ -484,226 +695,271 @@ class _CreatePostPageState extends State with WidgetsBindingObse child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 8.0), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - CommunitySelector( - account: account, - community: community, - onCommunitySelected: (ThunderCommunity c) { - setState(() { - communityId = c.id; - community = c; - }); - _onDraftInputChanged(); - _validateSubmission(); - }, - ), - const SizedBox(height: 4.0), - UserSelector( - account: account, - communityActorId: community?.actorId, - onCommunityChanged: (community) { - setState(() { - communityId = community?.id; - community = community; - }); - - _onDraftInputChanged(); - _validateSubmission(); - }, - onUserChanged: (account) { - setState(() { - userChanged = featureAccountState.effectiveAccount.id != account.id; - _draftAccountId = account.id; - }); - - context.read().setOverride(account); - _onDraftInputChanged(); - }, - enableAccountSwitching: widget.post == null, - ), - const SizedBox(height: 12.0), - TypeAheadField( - controller: _titleTextController, - suggestionsCallback: (String pattern) async { - if (pattern.isEmpty) { - String? linkTitle = await _getDataFromLink(link: _urlTextController.text, updateTitleField: false); - if (linkTitle?.isNotEmpty == true) { - return [linkTitle!]; - } - } - return []; - }, - itemBuilder: (BuildContext context, String itemData) { - return ListTile( - title: Text(itemData), - subtitle: Text(l10n.suggestedTitle), - ); - }, - onSelected: (String suggestion) { - _titleTextController.text = suggestion; - }, - builder: (context, controller, focusNode) => TextField( - controller: controller, - focusNode: focusNode, - decoration: InputDecoration( - labelText: l10n.postTitle, - helperText: l10n.requiredField, - isDense: true, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.all(13), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommunitySelector( + account: account, + community: community, + onCommunitySelected: (ThunderCommunity c) { + setState(() { + communityId = c.id; + community = c; + }); + _resetPiefedMetadata(status: _PiefedMetadataStatus.empty); + unawaited(_refreshPiefedMetadata()); + _onDraftInputChanged(); + _validateSubmission(); + }, ), - hideOnEmpty: true, - hideOnLoading: true, - hideOnError: true, - ), - const SizedBox(height: 10), - TextFormField( - controller: _urlTextController, - decoration: InputDecoration( - labelText: l10n.postURL, - errorText: urlError, - isDense: true, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.all(13), - suffixIcon: IconButton( - onPressed: () async { - if (state.status == CreatePostStatus.postImageUploadInProgress) { - return; - } - - List imagesPath = await selectImagesToUpload(); - if (context.mounted) { - context.read().uploadImages(imagesPath, isPostImage: true); + const SizedBox(height: 4.0), + UserSelector( + account: account, + communityActorId: community?.actorId, + onCommunityChanged: (community) { + setState(() { + communityId = community?.id; + this.community = community; + }); + + _resetPiefedMetadata(status: _PiefedMetadataStatus.empty); + unawaited(_refreshPiefedMetadata()); + _onDraftInputChanged(); + _validateSubmission(); + }, + onUserChanged: (account) { + setState(() { + userChanged = featureAccountState.effectiveAccount.id != account.id; + _draftAccountId = account.id; + }); + + context.read().setOverride(account); + _onDraftInputChanged(); + }, + enableAccountSwitching: widget.post == null, + ), + const SizedBox(height: 12.0), + TypeAheadField( + controller: _titleTextController, + suggestionsCallback: (String pattern) async { + if (pattern.isEmpty) { + String? linkTitle = await _getDataFromLink(link: _urlTextController.text, updateTitleField: false); + if (linkTitle?.isNotEmpty == true) { + return [linkTitle!]; } - }, - icon: state.status == CreatePostStatus.postImageUploadInProgress - ? const SizedBox( - width: 20, - height: 20, - child: Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(), - ), - ), - ) - : Icon(Icons.image, semanticLabel: l10n.uploadImage), + } + return []; + }, + itemBuilder: (BuildContext context, String itemData) { + return ListTile( + title: Text(itemData), + subtitle: Text(l10n.suggestedTitle), + ); + }, + onSelected: (String suggestion) { + _titleTextController.text = suggestion; + }, + builder: (context, controller, focusNode) => TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + labelText: l10n.postTitle, + helperText: l10n.requiredField, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.all(13), + ), ), + hideOnEmpty: true, + hideOnLoading: true, + hideOnError: true, ), - ), - if (!isImageUrl(_urlTextController.text)) ...[ const SizedBox(height: 10), TextFormField( - controller: _customThumbnailTextController, + controller: _urlTextController, decoration: InputDecoration( - labelText: l10n.thumbnailUrl, - errorText: customThumbnailError, + labelText: l10n.postURL, + errorText: urlError, isDense: true, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.all(13), + suffixIcon: IconButton( + onPressed: () async { + if (state.status == CreatePostStatus.postImageUploadInProgress) { + return; + } + + List imagesPath = await selectImagesToUpload(); + if (context.mounted) { + context.read().uploadImages(imagesPath, isPostImage: true); + } + }, + icon: state.status == CreatePostStatus.postImageUploadInProgress + ? const SizedBox( + width: 20, + height: 20, + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(), + ), + ), + ) + : Icon(Icons.image, semanticLabel: l10n.uploadImage), + ), ), ), - ], - if (isImageUrl(_urlTextController.text)) ...[ - const SizedBox(height: 10), - TextFormField( - controller: _altTextTextController, - decoration: InputDecoration( - labelText: l10n.altText, - isDense: true, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.all(13), + if (!isImageUrl(_urlTextController.text)) ...[ + const SizedBox(height: 10), + TextFormField( + controller: _customThumbnailTextController, + decoration: InputDecoration( + labelText: l10n.thumbnailUrl, + errorText: customThumbnailError, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.all(13), + ), ), - ), - ], - SizedBox(height: url.isNotEmpty ? 10 : 5), - Visibility( - visible: url.isNotEmpty, - child: MediaView( - showFullHeightImages: false, - edgeToEdgeImages: false, - viewMode: ViewMode.comfortable, - markPostReadOnMediaView: false, - isUserLoggedIn: true, - media: Media( - originalUrl: url, - mediaUrl: isImageUrl(url) - ? url - : customThumbnail?.isNotEmpty == true && isImageUrl(customThumbnail!) - ? customThumbnail - : null, - nsfw: isNSFW, - mediaType: MediaType.link, + ], + if (isImageUrl(_urlTextController.text)) ...[ + const SizedBox(height: 10), + TextFormField( + controller: _altTextTextController, + decoration: InputDecoration( + labelText: l10n.altText, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.all(13), + ), ), - ), - ), - if (crossPosts.isNotEmpty && widget.post == null) const SizedBox(height: 6), - Visibility( - visible: url.isNotEmpty && crossPosts.isNotEmpty, - child: CrossPosts( - crossPosts: crossPosts, - isNewPost: true, - ), - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.60), - child: LanguageSelector( - account: account, - languageId: languageId, - onLanguageSelected: (ThunderLanguage? language) { - setState(() => languageId = language?.id); - _onDraftInputChanged(); - }, + ], + if (_isPiefedComposer) ...[ + const SizedBox(height: 10), + TextFormField( + controller: _tagsTextController, + decoration: InputDecoration( + labelText: l10n.postTags, + helperText: l10n.postTagsHelperText, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.all(13), ), ), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(l10n.nsfw), - const SizedBox(width: 4.0), - Switch( - value: isNSFW, - onChanged: (bool value) { - setState(() => isNSFW = value); - _onDraftInputChanged(); - }, + const SizedBox(height: 10), + InkWell( + onTap: _hasSelectablePiefedFlairs ? _selectPostFlairs : null, + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: InputDecorator( + decoration: InputDecoration( + labelText: l10n.postFlairs, + helperText: _piefedFlairHelperText, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.all(13), + suffixIcon: _piefedFlairSuffixIcon, ), - ], + child: _selectedPiefedFlairIds.isEmpty + ? Text( + _hasSelectablePiefedFlairs ? l10n.postFlairs : l10n.postFlairsUnavailable, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), + ) + : Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedPiefedFlairs.map((flair) => Chip(label: Text(flair.title))).toList(), + ), + ), ), ], - ), - const SizedBox(height: 10), - AnimatedCrossFade( - firstChild: Container( - margin: const EdgeInsets.only(top: 8.0), - width: double.infinity, - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: getBackgroundColor(context), - borderRadius: const BorderRadius.all(Radius.circular(8.0)), + SizedBox(height: url.isNotEmpty ? 10 : 5), + Visibility( + visible: url.isNotEmpty, + child: MediaView( + showFullHeightImages: false, + edgeToEdgeImages: false, + viewMode: ViewMode.comfortable, + markPostReadOnMediaView: false, + isUserLoggedIn: true, + media: Media( + originalUrl: url, + mediaUrl: isImageUrl(url) + ? url + : customThumbnail?.isNotEmpty == true && isImageUrl(customThumbnail!) + ? customThumbnail + : null, + nsfw: isNSFW, + mediaType: MediaType.link, + ), ), - child: CommonMarkdownBody(body: _bodyTextController.text, isComment: true, nsfw: isNSFW && hideNsfwPreviews), ), - secondChild: MarkdownTextInputField( - controller: _bodyTextController, - focusNode: _bodyFocusNode, - label: l10n.postBody, - minLines: 8, - maxLines: null, - textStyle: theme.textTheme.bodyLarge, - spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + if (crossPosts.isNotEmpty && widget.post == null) const SizedBox(height: 6), + Visibility( + visible: url.isNotEmpty && crossPosts.isNotEmpty, + child: CrossPosts( + crossPosts: crossPosts, + isNewPost: true, + ), ), - crossFadeState: showPreview ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 120), - excludeBottomFocus: false, - ), - ]), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.60), + child: LanguageSelector( + account: account, + languageId: languageId, + onLanguageSelected: (ThunderLanguage? language) { + setState(() => languageId = language?.id); + _onDraftInputChanged(); + }, + ), + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(l10n.nsfw), + const SizedBox(width: 4.0), + Switch( + value: isNSFW, + onChanged: (bool value) { + setState(() => isNSFW = value); + _onDraftInputChanged(); + }, + ), + ], + ), + ], + ), + const SizedBox(height: 10), + AnimatedCrossFade( + firstChild: Container( + margin: const EdgeInsets.only(top: 8.0), + width: double.infinity, + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: getBackgroundColor(context), + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: CommonMarkdownBody(body: _bodyTextController.text, isComment: true, nsfw: isNSFW && hideNsfwPreviews), + ), + secondChild: MarkdownTextInputField( + controller: _bodyTextController, + focusNode: _bodyFocusNode, + label: l10n.postBody, + minLines: 8, + maxLines: null, + textStyle: theme.textTheme.bodyLarge, + spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + ), + crossFadeState: showPreview ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 120), + excludeBottomFocus: false, + ), + ], + ), ), ), ), @@ -884,6 +1140,8 @@ class _CreatePostPageState extends State with WidgetsBindingObse void _onCreatePost(BuildContext context) { saveDraft = false; + final flairIdList = _submissionFlairIds(); + context.read().createOrEditPost( communityId: communityId!, name: _titleTextController.text, @@ -892,6 +1150,8 @@ class _CreatePostPageState extends State with WidgetsBindingObse url: url, customThumbnail: customThumbnail, altText: altText, + tags: _submissionTags(), + flairIds: flairIdList, postIdBeingEdited: widget.post?.id, languageId: languageId, ); diff --git a/lib/src/features/post/presentation/state/create_post_cubit.dart b/lib/src/features/post/presentation/state/create_post_cubit.dart index bf496bd2a..0f50f6d91 100644 --- a/lib/src/features/post/presentation/state/create_post_cubit.dart +++ b/lib/src/features/post/presentation/state/create_post_cubit.dart @@ -132,6 +132,8 @@ class CreatePostCubit extends Cubit { String? url, String? customThumbnail, String? altText, + List? tags, + List? flairIds, bool? nsfw, int? postIdBeingEdited, int? languageId, @@ -150,6 +152,8 @@ class CreatePostCubit extends Cubit { url: url, customThumbnail: customThumbnail, altText: altText, + tags: tags, + flairIds: flairIds, nsfw: nsfw, postIdBeingEdited: postIdBeingEdited, languageId: languageId, diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body.dart b/lib/src/features/post/presentation/widgets/post_body/post_body.dart index e89019263..89df1a922 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body.dart @@ -206,6 +206,19 @@ class _PostBodyState extends State with SingleTickerProviderStateMixin ); } + if (post.tags.isNotEmpty) { + children.add( + Expandable( + controller: expandableController, + collapsed: const SizedBox.shrink(), + expanded: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + child: PostFlairTags(tags: post.tags), + ), + ), + ); + } + children.add( PostBodyMetadata( languageId: post.languageId, diff --git a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart index 934faeaa8..3e5c78800 100644 --- a/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart +++ b/lib/src/features/post/presentation/widgets/post_body/post_body_title.dart @@ -13,6 +13,7 @@ import 'package:thunder/packages/ui/ui.dart' show ScalableText; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/foundation/config/global_context.dart'; +import 'package:thunder/src/features/post/presentation/widgets/post_flair_tags.dart'; /// Displays the title and related information for a given post. /// @@ -109,10 +110,20 @@ class PostBodyTitle extends StatelessWidget { final theme = Theme.of(context); final titleFontSizeScale = context.select((cubit) => cubit.state.titleFontSizeScale); - return ScalableText( - post.name, - textScaleFactor: titleFontSizeScale.textScaleFactor, - style: theme.textTheme.titleMedium, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScalableText( + post.name, + textScaleFactor: titleFontSizeScale.textScaleFactor, + style: theme.textTheme.titleMedium, + ), + if (post.flairs.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: PostFlairTags(flairs: post.flairs), + ), + ], ); } diff --git a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart index 6c090d03d..1e60479dd 100644 --- a/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart +++ b/lib/src/features/post/presentation/widgets/post_bottom_sheet/post_action_bottom_sheet.dart @@ -238,7 +238,19 @@ class _PostActionBottomSheetState extends State { if (currentPage == GeneralPostAction.general) Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: LanguagePostCardMetaData(languageId: widget.post.languageId, account: account), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + LanguagePostCardMetaData(languageId: widget.post.languageId, account: account), + if (widget.post.flairs.isNotEmpty || widget.post.tags.isNotEmpty) ...[ + const SizedBox(height: 8.0), + if (widget.post.flairs.isNotEmpty) PostFlairTags(flairs: widget.post.flairs), + if (widget.post.flairs.isNotEmpty && widget.post.tags.isNotEmpty) const SizedBox(height: 8.0), + if (widget.post.tags.isNotEmpty) PostFlairTags(tags: widget.post.tags), + ], + ], + ), ), const SizedBox(height: 16.0), actions, diff --git a/lib/src/features/post/presentation/widgets/post_card_title.dart b/lib/src/features/post/presentation/widgets/post_card_title.dart index d394cfca8..8ef3c85c7 100644 --- a/lib/src/features/post/presentation/widgets/post_card_title.dart +++ b/lib/src/features/post/presentation/widgets/post_card_title.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/settings/api.dart'; import 'package:thunder/src/foundation/primitives/primitives.dart'; -import 'package:thunder/src/features/post/post.dart'; /// Creates the title of a post card. This includes the post title and any status icons. class PostCardTitle extends StatelessWidget { @@ -32,6 +32,9 @@ class PostCardTitle extends StatelessWidget { /// Determines whether the title should be dimmed or not. This is usually to indicate when a post has been read. final bool dim; + /// PieFed flair metadata attached to the post. + final List flairs; + const PostCardTitle({ super.key, required this.title, @@ -42,6 +45,7 @@ class PostCardTitle extends StatelessWidget { this.deleted = false, this.removed = false, this.dim = false, + this.flairs = const [], }); Color? _getDimmedColor(Color? color) => color?.withValues(alpha: 0.55); @@ -70,21 +74,31 @@ class PostCardTitle extends StatelessWidget { final statuses = PostStatusIcon(hidden: hidden, locked: locked, saved: saved, pinned: pinned, deleted: deleted, removed: removed, dim: dim); - return Text.rich( - TextSpan( - children: [ - WidgetSpan(child: statuses), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( TextSpan( - text: title, - style: textStyle?.copyWith( - fontWeight: FontWeight.w600, - fontSize: fontSize, - color: _getTitleColor(theme), - ), + children: [ + WidgetSpan(child: statuses), + TextSpan( + text: title, + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + fontSize: fontSize, + color: _getTitleColor(theme), + ), + ), + ], + ), + textScaler: TextScaler.noScaling, + ), + if (flairs.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: PostFlairTags(flairs: flairs, dim: dim), ), - ], - ), - textScaler: TextScaler.noScaling, + ], ); } } diff --git a/lib/src/features/post/presentation/widgets/post_flair_tags.dart b/lib/src/features/post/presentation/widgets/post_flair_tags.dart new file mode 100644 index 000000000..3f1375d54 --- /dev/null +++ b/lib/src/features/post/presentation/widgets/post_flair_tags.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; + +import 'package:thunder/src/foundation/primitives/primitives.dart'; + +/// Displays PieFed flairs and tags for a post. +class PostFlairTags extends StatelessWidget { + /// The flair metadata attached to the post. + final List flairs; + + /// The tags attached to the post. + final List tags; + + /// Whether the labels should be dimmed. + final bool dim; + + /// Maximum number of tags to display before collapsing into a counter. + final int? maxVisibleTags; + + const PostFlairTags({ + super.key, + this.flairs = const [], + this.tags = const [], + this.dim = false, + this.maxVisibleTags, + }); + + @override + Widget build(BuildContext context) { + if (flairs.isEmpty && tags.isEmpty) return const SizedBox.shrink(); + + final visibleTags = maxVisibleTags == null ? tags : tags.take(maxVisibleTags!).toList(); + final hiddenTagCount = tags.length - visibleTags.length; + final labels = [ + ...flairs.map(_PostLabel.flair), + ...visibleTags.map(_PostLabel.tag), + if (hiddenTagCount > 0) _PostLabel.tag('+$hiddenTagCount'), + ]; + + return Wrap( + spacing: 6.0, + runSpacing: 6.0, + children: labels.map((label) => _PostLabelChip(label: label, dim: dim)).toList(), + ); + } +} + +class _PostLabel { + /// The text to display in the label. + final String text; + + /// Whether the label is a flair or a tag. This is used to determine the default styling of the label. + final bool isFlair; + + /// The background color of the label. If null, a default color based on the label type will be used. + final Color? backgroundColor; + + /// The foreground color of the label. If null, a default color based on the label type will be used. + final Color? foregroundColor; + + const _PostLabel({required this.text, required this.isFlair, this.backgroundColor, this.foregroundColor}); + + factory _PostLabel.flair(ThunderFlair flair) { + return _PostLabel( + text: flair.title, + isFlair: true, + backgroundColor: flair.parsedBackgroundColor, + foregroundColor: flair.parsedTextColor, + ); + } + + factory _PostLabel.tag(String tag) { + return _PostLabel(text: tag.startsWith('+') ? tag : '#$tag', isFlair: false); + } +} + +class _PostLabelChip extends StatelessWidget { + /// The label to display, which can be either a flair or a tag. + final _PostLabel label; + + /// Whether the label should be dimmed. + final bool dim; + + const _PostLabelChip({required this.label, required this.dim}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final fallbackBackground = label.isFlair ? theme.colorScheme.secondaryContainer : theme.colorScheme.surfaceContainerHighest; + final fallbackForeground = label.isFlair ? theme.colorScheme.onSecondaryContainer : theme.colorScheme.onSurfaceVariant; + + final resolvedBackground = (label.backgroundColor ?? fallbackBackground).withValues(alpha: dim ? 0.55 : 1.0); + final resolvedForeground = (label.foregroundColor ?? fallbackForeground).withValues(alpha: dim ? 0.75 : 1.0); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 3.0), + decoration: BoxDecoration( + color: resolvedBackground, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: resolvedForeground, + fontWeight: label.isFlair ? FontWeight.w600 : FontWeight.w500, + height: 1.1, + ), + ), + ); + } +} diff --git a/lib/src/features/post/presentation/widgets/widgets.dart b/lib/src/features/post/presentation/widgets/widgets.dart index 6882ce59f..f56f45fe3 100644 --- a/lib/src/features/post/presentation/widgets/widgets.dart +++ b/lib/src/features/post/presentation/widgets/widgets.dart @@ -2,6 +2,7 @@ export 'post_bottom_sheet/community_post_action_bottom_sheet.dart'; export 'post_bottom_sheet/general_post_action_bottom_sheet.dart'; export 'post_bottom_sheet/post_action_bottom_sheet.dart'; export 'post_card_title.dart'; +export 'post_flair_tags.dart'; export 'post_page_app_bar.dart'; export 'post_bottom_sheet/post_post_action_bottom_sheet.dart'; export 'post_status_icon.dart'; diff --git a/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart index eca68e1e2..4ac699dee 100644 --- a/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart +++ b/lib/src/foundation/networking/lemmy/base_lemmy_api_client.dart @@ -119,6 +119,7 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli String? url, String? contents, String? altText, + String? tags, bool? nsfw, int? languageId, String? customThumbnail, @@ -126,6 +127,56 @@ abstract class BaseLemmyApiClient extends BaseApiClient implements ThunderApiCli throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); } + @override + Future createPostWithMetadata({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + List? tags, + List? flairIds, + }) { + return createPost( + title: title, + communityId: communityId, + url: url, + contents: contents, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + altText: altText, + ); + } + + @override + Future editPostWithMetadata({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + List? tags, + List? flairIds, + }) { + return editPost( + postId: postId, + title: title, + url: url, + contents: contents, + altText: altText, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + ); + } + @override Future votePost({required int postId, required int score}) async { throw UnimplementedError('Lemmy endpoints are implemented in version-specific clients.'); diff --git a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart index f80942499..9d982e2fd 100644 --- a/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart +++ b/lib/src/foundation/networking/lemmy/lemmy_v3_api_client.dart @@ -20,6 +20,7 @@ import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_flair.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; import 'package:thunder/src/features/account/domain/models/account_settings_update.dart'; @@ -196,6 +197,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { String? url, String? contents, String? altText, + String? tags, bool? nsfw, int? languageId, String? customThumbnail, @@ -475,6 +477,7 @@ class LemmyV3ApiClient extends BaseLemmyApiClient { site: json['site'] != null ? ThunderSite.fromLemmySite(json['site']) : null, moderators: (json['moderators'] as List).map((cmv) => parseUser(cmv['moderator'])).toList(), discussionLanguages: (json['discussion_languages'] as List).cast(), + flairs: const [], ); } diff --git a/lib/src/foundation/networking/piefed/piefed_api_client.dart b/lib/src/foundation/networking/piefed/piefed_api_client.dart index 9e512ec09..49971964f 100644 --- a/lib/src/foundation/networking/piefed/piefed_api_client.dart +++ b/lib/src/foundation/networking/piefed/piefed_api_client.dart @@ -9,6 +9,7 @@ import 'package:thunder/src/foundation/primitives/enums/post_sort_type.dart'; import 'package:thunder/src/foundation/primitives/enums/search_sort_type.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_comment_report.dart'; import 'package:thunder/src/foundation/primitives/models/modlog_event_item.dart'; +import 'package:thunder/src/foundation/primitives/models/piefed_post_metadata.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_post_report.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_private_message.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_site.dart'; @@ -18,6 +19,7 @@ import 'package:thunder/src/foundation/networking/base_api_client.dart'; import 'package:thunder/src/foundation/networking/thunder_api_client.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_flair.dart'; import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; @@ -180,6 +182,44 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { return ThunderPost.fromPiefedPostView(json['post_view']); } + @override + Future createPostWithMetadata({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + List? tags, + List? flairIds, + }) async { + ThunderPost post = await createPost( + title: title, + communityId: communityId, + url: url, + contents: contents, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + altText: altText, + ); + + return _applyPostMetadata( + post: post, + title: title, + url: url, + contents: contents, + altText: altText, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + tags: tags, + flairIds: flairIds, + ); + } + @override Future editPost({ required int postId, @@ -187,6 +227,7 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { String? url, String? contents, String? altText, + String? tags, bool? nsfw, int? languageId, String? customThumbnail, @@ -197,12 +238,51 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { 'url': url, 'body': contents, 'alt_text': altText, + 'tags': tags, 'nsfw': nsfw, 'language_id': languageId, }); return ThunderPost.fromPiefedPostView(json['post_view']); } + @override + Future editPostWithMetadata({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + List? tags, + List? flairIds, + }) async { + ThunderPost post = await editPost( + postId: postId, + title: title, + url: url, + contents: contents, + altText: altText, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + ); + + return _applyPostMetadata( + post: post, + title: title, + url: url, + contents: contents, + altText: altText, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + tags: tags, + flairIds: flairIds, + ); + } + @override Future votePost({required int postId, required int score}) async { final json = await request(HttpMethod.post, '$basePath/post/like', { @@ -342,11 +422,12 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { } /// Assign flair to a post. - Future> setPostFlair({required int postId, List? flairIdList}) async { - return await request(HttpMethod.post, '$basePath/post/assign_flair', { + Future setPostFlair({required int postId, List? flairIds}) async { + final json = await request(HttpMethod.post, '$basePath/post/assign_flair', { 'post_id': postId, - 'flair_id_list': flairIdList, + 'flair_id_list': flairIds, }); + return ThunderPost.fromPiefedPostView(json); } /// Subscribe or unsubscribe from a post. @@ -639,9 +720,48 @@ class PiefedApiClient extends BaseApiClient implements ThunderApiClient { site: json['site'] != null ? ThunderSite.fromPiefedSite(json['site']) : null, moderators: (json['moderators'] as List).map((cmv) => ThunderUser.fromPiefedUser(cmv['moderator'])).toList(), discussionLanguages: (json['discussion_languages'] as List?)?.cast() ?? [], + flairs: ThunderFlair.parsePiefedList(json['community_view']?['flair_list']), ); } + Future _applyPostMetadata({ + required ThunderPost post, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + List? tags, + List? flairIds, + }) async { + ThunderPost updatedPost = post; + + if (tags != null) { + updatedPost = await editPost( + postId: post.id, + title: title, + url: url, + contents: contents, + altText: altText, + tags: encodePiefedTags(tags), + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + ); + } + + if (flairIds != null) { + updatedPost = await setPostFlair( + postId: post.id, + flairIds: normalizePiefedFlairIds(flairIds), + ); + } + + return updatedPost; + } + @override Future> getCommunities({ int? page, diff --git a/lib/src/foundation/networking/thunder_api_client.dart b/lib/src/foundation/networking/thunder_api_client.dart index dd731bb86..c4da80833 100644 --- a/lib/src/foundation/networking/thunder_api_client.dart +++ b/lib/src/foundation/networking/thunder_api_client.dart @@ -12,6 +12,7 @@ import 'package:thunder/src/foundation/primitives/models/thunder_site_response.d import 'package:thunder/src/foundation/primitives/models/thunder_comment.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; import 'package:thunder/src/foundation/primitives/enums/modlog_action_type.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_flair.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_post.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; import 'package:thunder/src/features/account/domain/models/account_settings_update.dart'; @@ -41,6 +42,7 @@ typedef GetCommunityResponse = ({ ThunderSite? site, List moderators, List discussionLanguages, + List flairs, }); /// Response from getting a user. @@ -143,11 +145,65 @@ abstract class ThunderApiClient { String? url, String? contents, String? altText, + String? tags, bool? nsfw, int? languageId, String? customThumbnail, }); + /// Create a new post and apply any platform-specific metadata. + Future createPostWithMetadata({ + required String title, + required int communityId, + String? url, + String? contents, + bool? nsfw, + int? languageId, + String? customThumbnail, + String? altText, + List? tags, + List? flairIds, + }) async { + return createPost( + title: title, + communityId: communityId, + url: url, + contents: contents, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + altText: altText, + ); + } + + /// Edit an existing post and apply any platform-specific metadata. + /// + /// For metadata lists, `null` leaves existing values unchanged, an empty list clears them, + /// and a non-empty list applies the supplied values. + Future editPostWithMetadata({ + required int postId, + required String title, + String? url, + String? contents, + String? altText, + bool? nsfw, + int? languageId, + String? customThumbnail, + List? tags, + List? flairIds, + }) async { + return editPost( + postId: postId, + title: title, + url: url, + contents: contents, + altText: altText, + nsfw: nsfw, + languageId: languageId, + customThumbnail: customThumbnail, + ); + } + /// Vote on a post. Future votePost({required int postId, required int score}); diff --git a/lib/src/foundation/primitives/models/models.dart b/lib/src/foundation/primitives/models/models.dart index 7d2ca1dfb..9089d9616 100644 --- a/lib/src/foundation/primitives/models/models.dart +++ b/lib/src/foundation/primitives/models/models.dart @@ -1,9 +1,11 @@ export 'media.dart'; export 'modlog_event_item.dart'; export 'parsed_link.dart'; +export 'piefed_post_metadata.dart'; export 'thunder_comment.dart'; export 'thunder_comment_report.dart'; export 'thunder_community.dart'; +export 'thunder_flair.dart'; export 'thunder_instance_info.dart'; export 'thunder_language.dart'; export 'thunder_local_user.dart'; diff --git a/lib/src/foundation/primitives/models/piefed_post_metadata.dart b/lib/src/foundation/primitives/models/piefed_post_metadata.dart new file mode 100644 index 000000000..73d6c99d3 --- /dev/null +++ b/lib/src/foundation/primitives/models/piefed_post_metadata.dart @@ -0,0 +1,95 @@ +import 'package:collection/collection.dart'; + +List decodePiefedComposerTags(String? value) { + if (value == null || value.trim().isEmpty) { + return const []; + } + + return _normalizePiefedTags(value.split(',')); +} + +List parsePiefedTags(dynamic value) { + return switch (value) { + String() => decodePiefedComposerTags(value), + Iterable() => _normalizePiefedTags( + value.map((tag) => switch (tag) { + String() => tag, + Map() => (tag['name'] ?? tag['tag'] ?? tag['title'] ?? '').toString(), + _ => '', + }), + ), + _ => const [], + }; +} + +List normalizePiefedTags(Iterable? tags) { + if (tags == null) { + return const []; + } + + return _normalizePiefedTags(tags); +} + +String encodePiefedTags(Iterable? tags) => normalizePiefedTags(tags).join(', '); + +List normalizePiefedFlairIds(Iterable? flairIds) { + if (flairIds == null) { + return const []; + } + + return flairIds.toSet().toList(); +} + +List? resolveSubmittedPiefedTags( + String? composerText, { + Iterable? originalTags, +}) { + final normalizedTags = decodePiefedComposerTags(composerText); + if (originalTags == null) { + return normalizedTags.isEmpty ? null : normalizedTags; + } + + return const ListEquality().equals(normalizedTags, normalizePiefedTags(originalTags)) ? null : normalizedTags; +} + +List? resolveSubmittedPiefedFlairIds( + Iterable? selectedFlairIds, { + Iterable? originalFlairIds, +}) { + final normalizedFlairIds = normalizePiefedFlairIds(selectedFlairIds); + if (originalFlairIds == null) { + return normalizedFlairIds.isEmpty ? null : normalizedFlairIds; + } + + return const SetEquality().equals(normalizedFlairIds.toSet(), normalizePiefedFlairIds(originalFlairIds).toSet()) ? null : normalizedFlairIds; +} + +List retainValidPiefedFlairSelection({ + required List selectedFlairIds, + required Iterable availableFlairIds, + required bool clearWhenUnavailable, +}) { + final normalizedSelection = normalizePiefedFlairIds(selectedFlairIds); + final validFlairIds = availableFlairIds.toSet(); + + if (validFlairIds.isEmpty) { + return clearWhenUnavailable ? const [] : normalizedSelection; + } + + return normalizedSelection.where(validFlairIds.contains).toList(); +} + +List _normalizePiefedTags(Iterable tags) { + final normalized = []; + + for (final tag in tags) { + final trimmed = tag.trim().replaceFirst(RegExp(r'^#+'), ''); + if (trimmed.isEmpty || normalized.contains(trimmed)) { + continue; + } + + normalized.add(trimmed); + } + + return normalized; +} diff --git a/lib/src/foundation/primitives/models/thunder_flair.dart b/lib/src/foundation/primitives/models/thunder_flair.dart new file mode 100644 index 000000000..dadbb313f --- /dev/null +++ b/lib/src/foundation/primitives/models/thunder_flair.dart @@ -0,0 +1,71 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class ThunderFlair extends Equatable { + /// The flair's ID. + final int id; + + /// The community this flair belongs to. + final int communityId; + + /// The label shown for the flair. + final String title; + + /// Hex color code for the flair text. + final String textColor; + + /// Hex color code for the flair background. + final String backgroundColor; + + const ThunderFlair({ + required this.id, + required this.communityId, + required this.title, + required this.textColor, + required this.backgroundColor, + }); + + /// Parsed text color for display, if the hex value is valid. + Color? get parsedTextColor => _parseHexColor(textColor); + + /// Parsed background color for display, if the hex value is valid. + Color? get parsedBackgroundColor => _parseHexColor(backgroundColor); + + @override + List get props => [id, communityId, title, textColor, backgroundColor]; + + factory ThunderFlair.fromPiefedFlair(Map flair) { + return ThunderFlair( + id: flair['id'], + communityId: flair['community_id'], + title: flair['flair_title'], + textColor: flair['text_color'], + backgroundColor: flair['background_color'], + ); + } + + static List parsePiefedList(dynamic flairs) { + return (flairs as List?)?.whereType().map((flair) => ThunderFlair.fromPiefedFlair(Map.from(flair))).toList() ?? const []; + } + + static Color? _parseHexColor(String? value) { + if (value == null) return null; + + final normalized = value.trim().replaceFirst('#', ''); + if (normalized.isEmpty) return null; + + final hex = switch (normalized.length) { + 6 => 'FF$normalized', + 8 => normalized, + _ => null, + }; + + if (hex == null) return null; + + try { + return Color(int.parse(hex, radix: 16)); + } catch (_) { + return null; + } + } +} diff --git a/lib/src/foundation/primitives/models/thunder_post.dart b/lib/src/foundation/primitives/models/thunder_post.dart index 216744f45..37ffd6428 100644 --- a/lib/src/foundation/primitives/models/thunder_post.dart +++ b/lib/src/foundation/primitives/models/thunder_post.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:thunder/src/foundation/primitives/enums/subscription_status.dart'; +import 'package:thunder/src/foundation/primitives/models/piefed_post_metadata.dart'; +import 'package:thunder/src/foundation/primitives/models/thunder_flair.dart'; import 'package:thunder/src/foundation/primitives/models/media.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_community.dart'; import 'package:thunder/src/foundation/primitives/models/thunder_user.dart'; @@ -128,6 +130,12 @@ class ThunderPost extends Equatable { /// The media associated with the post final List media; + /// Tags returned by PieFed. + final List tags; + + /// Flair metadata returned by PieFed. + final List flairs; + const ThunderPost({ required this.id, required this.name, @@ -168,6 +176,8 @@ class ThunderPost extends Equatable { this.myVote, this.unreadComments, this.media = const [], + this.tags = const [], + this.flairs = const [], this.textPreview, }); @@ -212,6 +222,8 @@ class ThunderPost extends Equatable { myVote, unreadComments, media, + tags, + flairs, textPreview, ]; @@ -255,6 +267,8 @@ class ThunderPost extends Equatable { int? myVote, int? unreadComments, List? media, + List? tags, + List? flairs, String? textPreview, }) { return ThunderPost( @@ -297,6 +311,8 @@ class ThunderPost extends Equatable { myVote: myVote ?? this.myVote, unreadComments: unreadComments ?? this.unreadComments, media: media ?? this.media, + tags: tags ?? this.tags, + flairs: flairs ?? this.flairs, textPreview: textPreview ?? this.textPreview, ); } @@ -406,6 +422,8 @@ class ThunderPost extends Equatable { featuredCommunity: post['sticky'], featuredLocal: false, // Not available in PieFed altText: post['alt_text'], + tags: parsePiefedTags(post['tags']), + flairs: ThunderFlair.parsePiefedList(postView['flair_list']), creator: ThunderUser.fromPiefedUser(creator), community: ThunderCommunity.fromPiefedCommunity(community, subscribed: subscribed), imageDetails: post['image_details'], @@ -449,6 +467,8 @@ class ThunderPost extends Equatable { featuredCommunity: post['sticky'], featuredLocal: false, // Not available in PieFed altText: post['alt_text'], + tags: parsePiefedTags(post['tags']), + flairs: ThunderFlair.parsePiefedList(post['flair_list']), media: media, ); }