feature: Add new home carousel (#58)

## Pull Request Description

This adds a new home carousel better suited for mobile
The old one is still available

## Checklist

- [x] If a new package was added, did you ensure it works for all
supported platforms? Is the package also well maintained?
- [x] Did you add localization for any text? If yes, did you sort the
.arb file using ```arb_utils sort <INPUT_FILE>```?
- [x] Check that any changes are related to the issue at hand.

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2024-10-21 22:24:59 +02:00 committed by GitHub
parent 2a2502147a
commit d572884e61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1696 additions and 393 deletions

View file

@ -1,8 +1,11 @@
{ {
"@@locale": "en", "@@locale": "en",
"about": "About", "about": "About",
"@about": {},
"accept": "Accept", "accept": "Accept",
"@accept": {},
"active": "Active", "active": "Active",
"@active": {},
"actor": "{count, plural, other{Actors} one{Actor}}", "actor": "{count, plural, other{Actors} one{Actor}}",
"@actor": { "@actor": {
"description": "actor", "description": "actor",
@ -14,14 +17,23 @@
} }
}, },
"addAsFavorite": "Add as favorite", "addAsFavorite": "Add as favorite",
"@addAsFavorite": {},
"addToCollection": "Add to collection", "addToCollection": "Add to collection",
"@addToCollection": {},
"addToPlaylist": "Add to playlist", "addToPlaylist": "Add to playlist",
"@addToPlaylist": {},
"advanced": "Advanced", "advanced": "Advanced",
"@advanced": {},
"all": "All", "all": "All",
"@all": {},
"amoledBlack": "Amoled black", "amoledBlack": "Amoled black",
"@amoledBlack": {},
"appLockAutoLogin": "Auto login", "appLockAutoLogin": "Auto login",
"@appLockAutoLogin": {},
"appLockBiometrics": "Biometrics", "appLockBiometrics": "Biometrics",
"@appLockBiometrics": {},
"appLockPasscode": "Passcode", "appLockPasscode": "Passcode",
"@appLockPasscode": {},
"appLockTitle": "Set the log-in method for {userName}", "appLockTitle": "Set the log-in method for {userName}",
"@appLockTitle": { "@appLockTitle": {
"description": "Pop-up to pick a login method", "description": "Pop-up to pick a login method",
@ -32,15 +44,24 @@
} }
}, },
"ascending": "Ascending", "ascending": "Ascending",
"@ascending": {},
"audio": "Audio", "audio": "Audio",
"@audio": {},
"autoPlay": "Auto-play", "autoPlay": "Auto-play",
"@autoPlay": {},
"backgroundBlur": "Background blur", "backgroundBlur": "Background blur",
"@backgroundBlur": {},
"backgroundOpacity": "Background opacity", "backgroundOpacity": "Background opacity",
"biometricsFailedCheckAgain": "Biometrics failed check settings and try again", "@backgroundOpacity": {},
"biometricsFailedCheckAgain": "Biometrics failed. Check settings and try again.",
"@biometricsFailedCheckAgain": {},
"bold": "Bold", "bold": "Bold",
"@bold": {},
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": {},
"change": "Change", "change": "Change",
"chapter": "{count, plural, other{Chapters} one{Chapter}}", "@change": {},
"chapter": "{count, plural, other{Chapters} one{Chapter}}",
"@chapter": { "@chapter": {
"description": "chapter", "description": "chapter",
"placeholders": { "placeholders": {
@ -51,16 +72,27 @@
} }
}, },
"clear": "Clear", "clear": "Clear",
"@clear": {},
"clearAllSettings": "Clear all settings", "clearAllSettings": "Clear all settings",
"@clearAllSettings": {},
"clearAllSettingsQuestion": "Clear all settings?", "clearAllSettingsQuestion": "Clear all settings?",
"@clearAllSettingsQuestion": {},
"clearChanges": "Clear changes", "clearChanges": "Clear changes",
"@clearChanges": {},
"clearSelection": "Clear selection", "clearSelection": "Clear selection",
"@clearSelection": {},
"close": "Close", "close": "Close",
"@close": {},
"code": "Code", "code": "Code",
"@code": {},
"collectionFolder": "Collection folder", "collectionFolder": "Collection folder",
"@collectionFolder": {},
"color": "Color", "color": "Color",
"@color": {},
"combined": "Combined", "combined": "Combined",
"@combined": {},
"communityRating": "Community Rating", "communityRating": "Community Rating",
"@communityRating": {},
"continuePage": "Continue - page {page}", "continuePage": "Continue - page {page}",
"@continuePage": { "@continuePage": {
"description": "Continue - page 1", "description": "Continue - page 1",
@ -71,12 +103,19 @@
} }
}, },
"controls": "Controls", "controls": "Controls",
"@controls": {},
"dashboard": "Dashboard", "dashboard": "Dashboard",
"@dashboard": {},
"dashboardContinue": "Continue", "dashboardContinue": "Continue",
"@dashboardContinue": {},
"dashboardContinueListening": "Continue Listening", "dashboardContinueListening": "Continue Listening",
"@dashboardContinueListening": {},
"dashboardContinueReading": "Continue Reading", "dashboardContinueReading": "Continue Reading",
"@dashboardContinueReading": {},
"dashboardContinueWatching": "Continue Watching", "dashboardContinueWatching": "Continue Watching",
"@dashboardContinueWatching": {},
"dashboardNextUp": "Next-up", "dashboardNextUp": "Next-up",
"@dashboardNextUp": {},
"dashboardRecentlyAdded": "Recently added in {name}", "dashboardRecentlyAdded": "Recently added in {name}",
"@dashboardRecentlyAdded": { "@dashboardRecentlyAdded": {
"description": "Recently added on home screen", "description": "Recently added on home screen",
@ -87,10 +126,15 @@
} }
}, },
"dateAdded": "Date added", "dateAdded": "Date added",
"@dateAdded": {},
"dateLastContentAdded": "Date last content added", "dateLastContentAdded": "Date last content added",
"@dateLastContentAdded": {},
"datePlayed": "Date played", "datePlayed": "Date played",
"@datePlayed": {},
"days": "Days", "days": "Days",
"@days": {},
"delete": "Delete", "delete": "Delete",
"@delete": {},
"deleteFileFromSystem": "Deleting this item {item} will delete it from both the file system and your media library.\nAre you sure you wish to continue?", "deleteFileFromSystem": "Deleting this item {item} will delete it from both the file system and your media library.\nAre you sure you wish to continue?",
"@deleteFileFromSystem": { "@deleteFileFromSystem": {
"description": "Delete file from system", "description": "Delete file from system",
@ -110,7 +154,8 @@
} }
}, },
"descending": "Descending", "descending": "Descending",
"director": "{count, plural, other{Director} two{Directors}}", "@descending": {},
"director": "{count, plural, other{Director} two{Directors}}",
"@director": { "@director": {
"description": "director", "description": "director",
"placeholders": { "placeholders": {
@ -120,19 +165,32 @@
} }
} }
}, },
"disableFilters": "Disable filters", "disableFilters": "Turn off filters",
"disabled": "Disabled", "@disableFilters": {},
"disabled": "Off",
"@disabled": {},
"discovered": "Discovered", "discovered": "Discovered",
"@discovered": {},
"displayLanguage": "Display language", "displayLanguage": "Display language",
"downloadsClearDesc": "Are you sure you want to remove all synced data?\nThis will clear all data for every synced user!", "@displayLanguage": {},
"downloadsClearDesc": "Remove all synced data, clearing\nall data for every synced user?",
"@downloadsClearDesc": {},
"downloadsClearTitle": "Clear synced data", "downloadsClearTitle": "Clear synced data",
"@downloadsClearTitle": {},
"downloadsPath": "Path", "downloadsPath": "Path",
"@downloadsPath": {},
"downloadsSyncedData": "Synced data", "downloadsSyncedData": "Synced data",
"@downloadsSyncedData": {},
"downloadsTitle": "Downloads", "downloadsTitle": "Downloads",
"@downloadsTitle": {},
"dynamicText": "Dynamic", "dynamicText": "Dynamic",
"@dynamicText": {},
"editMetadata": "Edit metadata", "editMetadata": "Edit metadata",
"@editMetadata": {},
"empty": "Empty", "empty": "Empty",
"enabled": "Enabled", "@empty": {},
"enabled": "On",
"@enabled": {},
"endsAt": "ends at {date}", "endsAt": "ends at {date}",
"@endsAt": { "@endsAt": {
"description": "endsAt", "description": "endsAt",
@ -154,10 +212,15 @@
} }
}, },
"error": "Error", "error": "Error",
"failedToLoadImage": "Failed to load image", "@error": {},
"failedToLoadImage": "Could not load image",
"@failedToLoadImage": {},
"favorite": "Favorite", "favorite": "Favorite",
"@favorite": {},
"favorites": "Favorites", "favorites": "Favorites",
"fetchingLibrary": "Fetching library items", "@favorites": {},
"fetchingLibrary": "Fetching library items…",
"@fetchingLibrary": {},
"filter": "{count, plural, other{Filters} one{Filter}}", "filter": "{count, plural, other{Filters} one{Filter}}",
"@filter": { "@filter": {
"description": "filter", "description": "filter",
@ -169,9 +232,13 @@
} }
}, },
"folders": "Folders", "folders": "Folders",
"@folders": {},
"fontColor": "Font color", "fontColor": "Font color",
"@fontColor": {},
"fontSize": "Font size", "fontSize": "Font size",
"@fontSize": {},
"forceRefresh": "Force refresh", "forceRefresh": "Force refresh",
"@forceRefresh": {},
"genre": "{count, plural, other{Genres} one{Genre}}", "genre": "{count, plural, other{Genres} one{Genre}}",
"@genre": { "@genre": {
"description": "genre", "description": "genre",
@ -183,19 +250,35 @@
} }
}, },
"goTo": "Go To", "goTo": "Go To",
"@goTo": {},
"grid": "Grid", "grid": "Grid",
"@grid": {},
"group": "Group", "group": "Group",
"@group": {},
"groupBy": "Group by", "groupBy": "Group by",
"@groupBy": {},
"heightOffset": "Height offset", "heightOffset": "Height offset",
"@heightOffset": {},
"hide": "Hide", "hide": "Hide",
"@hide": {},
"hideEmpty": "Hide empty", "hideEmpty": "Hide empty",
"@hideEmpty": {},
"home": "Home", "home": "Home",
"@home": {},
"homeBannerBanner": "Banner",
"homeBannerCarousel": "Carousel",
"identify": "Identify", "identify": "Identify",
"@identify": {},
"immediately": "Immediately", "immediately": "Immediately",
"incorrectPinTryAgain": "Incorrect pin try again", "@immediately": {},
"incorrectPinTryAgain": "Incorrect PIN. Try again.",
"@incorrectPinTryAgain": {},
"info": "Info", "info": "Info",
"invalidUrl": "Invalid url", "@info": {},
"invalidUrlDesc": "Url needs to start with http(s)://", "invalidUrl": "Invalid URL",
"@invalidUrl": {},
"invalidUrlDesc": "URL needs to start with http(s)://",
"@invalidUrlDesc": {},
"itemCount": "Item count: {count}", "itemCount": "Item count: {count}",
"@itemCount": { "@itemCount": {
"description": "Item count", "description": "Item count",
@ -205,7 +288,7 @@
} }
} }
}, },
"label": "{count, plural, other{Labels} one{Label}}", "label": "{count, plural, other{Labels} one{Label}}",
"@label": { "@label": {
"description": "label", "description": "label",
"placeholders": { "placeholders": {
@ -225,16 +308,25 @@
} }
} }
}, },
"libraryFetchNoItemsFound": "No items found, try different settings.", "libraryFetchNoItemsFound": "No items found. Try different settings.",
"libraryPageSizeDesc": "Set the amount to load at a time. 0 disables paging", "@libraryFetchNoItemsFound": {},
"libraryPageSizeDesc": "Set the amount to load at a time. 0 turns off paging.",
"@libraryPageSizeDesc": {},
"libraryPageSizeTitle": "Library page size", "libraryPageSizeTitle": "Library page size",
"@libraryPageSizeTitle": {},
"light": "Light", "light": "Light",
"@light": {},
"list": "List", "list": "List",
"@list": {},
"lockscreen": "Lockscreen", "lockscreen": "Lockscreen",
"@lockscreen": {},
"loggedIn": "Logged-in", "loggedIn": "Logged-in",
"login": "Login", "@loggedIn": {},
"logout": "Logout", "login": "Log in",
"logoutUserPopupContent": "This will log-out {userName} and delete te user from the app.\nYou will have to log back in to {serverName}.", "@login": {},
"logout": "Log out",
"@logout": {},
"logoutUserPopupContent": "This will log out {userName} and delete the user from the app.\nYou will have to log back in on {serverName}.",
"@logoutUserPopupContent": { "@logoutUserPopupContent": {
"description": "Pop-up for loging out the user description", "description": "Pop-up for loging out the user description",
"placeholders": { "placeholders": {
@ -246,7 +338,7 @@
} }
} }
}, },
"logoutUserPopupTitle": "Log-out user {userName}?", "logoutUserPopupTitle": "Log out {userName}?",
"@logoutUserPopupTitle": { "@logoutUserPopupTitle": {
"description": "Pop-up for loging out the user", "description": "Pop-up for loging out the user",
"placeholders": { "placeholders": {
@ -256,21 +348,37 @@
} }
}, },
"loop": "Loop", "loop": "Loop",
"@loop": {},
"markAsUnwatched": "Mark as unwatched", "markAsUnwatched": "Mark as unwatched",
"@markAsUnwatched": {},
"markAsWatched": "Mark as watched", "markAsWatched": "Mark as watched",
"@markAsWatched": {},
"masonry": "Masonry", "masonry": "Masonry",
"@masonry": {},
"mediaTypeBase": "Base Type", "mediaTypeBase": "Base Type",
"@mediaTypeBase": {},
"mediaTypeBook": "Book", "mediaTypeBook": "Book",
"@mediaTypeBook": {},
"mediaTypeBoxset": "Boxset", "mediaTypeBoxset": "Boxset",
"@mediaTypeBoxset": {},
"mediaTypeEpisode": "Episode", "mediaTypeEpisode": "Episode",
"@mediaTypeEpisode": {},
"mediaTypeFolder": "Folder", "mediaTypeFolder": "Folder",
"@mediaTypeFolder": {},
"mediaTypeMovie": "Movie", "mediaTypeMovie": "Movie",
"@mediaTypeMovie": {},
"mediaTypePerson": "Person", "mediaTypePerson": "Person",
"@mediaTypePerson": {},
"mediaTypePhoto": "Photo", "mediaTypePhoto": "Photo",
"@mediaTypePhoto": {},
"mediaTypePhotoAlbum": "Photo Album", "mediaTypePhotoAlbum": "Photo Album",
"@mediaTypePhotoAlbum": {},
"mediaTypePlaylist": "Playlist", "mediaTypePlaylist": "Playlist",
"@mediaTypePlaylist": {},
"mediaTypeSeason": "Season", "mediaTypeSeason": "Season",
"@mediaTypeSeason": {},
"mediaTypeSeries": "Series", "mediaTypeSeries": "Series",
"@mediaTypeSeries": {},
"metaDataSavedFor": "Metadata saved for {item}", "metaDataSavedFor": "Metadata saved for {item}",
"@metaDataSavedFor": { "@metaDataSavedFor": {
"description": "metaDataSavedFor", "description": "metaDataSavedFor",
@ -281,8 +389,11 @@
} }
}, },
"metadataRefreshDefault": "Scan for new and updated files", "metadataRefreshDefault": "Scan for new and updated files",
"@metadataRefreshDefault": {},
"metadataRefreshFull": "Replace all metadata", "metadataRefreshFull": "Replace all metadata",
"@metadataRefreshFull": {},
"metadataRefreshValidation": "Search for missing metadata", "metadataRefreshValidation": "Search for missing metadata",
"@metadataRefreshValidation": {},
"minutes": "{count, plural, other{Minutes} one{Minute} }", "minutes": "{count, plural, other{Minutes} one{Minute} }",
"@minutes": { "@minutes": {
"description": "minute", "description": "minute",
@ -294,6 +405,7 @@
} }
}, },
"mode": "Mode", "mode": "Mode",
"@mode": {},
"moreFrom": "More from {info}", "moreFrom": "More from {info}",
"@moreFrom": { "@moreFrom": {
"description": "More from", "description": "More from",
@ -304,32 +416,61 @@
} }
}, },
"moreOptions": "More options", "moreOptions": "More options",
"@moreOptions": {},
"mouseDragSupport": "Drag using mouse", "mouseDragSupport": "Drag using mouse",
"@mouseDragSupport": {},
"musicAlbum": "Album", "musicAlbum": "Album",
"@musicAlbum": {},
"name": "Name", "name": "Name",
"@name": {},
"nativeName": "English",
"@nativeName": {},
"navigation": "Navigation", "navigation": "Navigation",
"@navigation": {},
"navigationDashboard": "Dashboard", "navigationDashboard": "Dashboard",
"@navigationDashboard": {},
"navigationFavorites": "Favorites", "navigationFavorites": "Favorites",
"@navigationFavorites": {},
"navigationSync": "Synced", "navigationSync": "Synced",
"@navigationSync": {},
"never": "Never", "never": "Never",
"@never": {},
"nextUp": "Next Up", "nextUp": "Next Up",
"@nextUp": {},
"noItemsSynced": "No items synced", "noItemsSynced": "No items synced",
"@noItemsSynced": {},
"noItemsToShow": "No items to show", "noItemsToShow": "No items to show",
"@noItemsToShow": {},
"noRating": "No rating", "noRating": "No rating",
"@noRating": {},
"noResults": "No results", "noResults": "No results",
"@noResults": {},
"noServersFound": "No new servers found", "noServersFound": "No new servers found",
"@noServersFound": {},
"noSuggestionsFound": "No suggestions found", "noSuggestionsFound": "No suggestions found",
"@noSuggestionsFound": {},
"none": "None", "none": "None",
"@none": {},
"normal": "Normal", "normal": "Normal",
"@normal": {},
"notPartOfAlbum": "Not part of a album", "notPartOfAlbum": "Not part of a album",
"@notPartOfAlbum": {},
"openParent": "Open parent", "openParent": "Open parent",
"@openParent": {},
"openShow": "Open show", "openShow": "Open show",
"@openShow": {},
"openWebLink": "Open web link", "openWebLink": "Open web link",
"@openWebLink": {},
"options": "Options", "options": "Options",
"@options": {},
"other": "Other", "other": "Other",
"@other": {},
"outlineColor": "Outline color", "outlineColor": "Outline color",
"@outlineColor": {},
"outlineSize": "Outline size", "outlineSize": "Outline size",
"@outlineSize": {},
"overview": "Overview", "overview": "Overview",
"@overview": {},
"page": "Page {index}", "page": "Page {index}",
"@page": { "@page": {
"description": "page", "description": "page",
@ -340,11 +481,17 @@
} }
}, },
"parentalRating": "Parental Rating", "parentalRating": "Parental Rating",
"@parentalRating": {},
"password": "Password", "password": "Password",
"@password": {},
"pathClearTitle": "Clear downloads path", "pathClearTitle": "Clear downloads path",
"@pathClearTitle": {},
"pathEditDesc": "This location is set for all users, any synced data will no longer be accessible.\nIt will remain on your storage.", "pathEditDesc": "This location is set for all users, any synced data will no longer be accessible.\nIt will remain on your storage.",
"@pathEditDesc": {},
"pathEditSelect": "Select downloads destination", "pathEditSelect": "Select downloads destination",
"@pathEditSelect": {},
"pathEditTitle": "Change location", "pathEditTitle": "Change location",
"@pathEditTitle": {},
"play": "Play {item}", "play": "Play {item}",
"@play": { "@play": {
"description": "Play with", "description": "Play with",
@ -355,6 +502,7 @@
} }
}, },
"playCount": "Play count", "playCount": "Play count",
"@playCount": {},
"playFrom": "Play from {name}", "playFrom": "Play from {name}",
"@playFrom": { "@playFrom": {
"description": "playFrom", "description": "playFrom",
@ -374,13 +522,21 @@
} }
}, },
"playLabel": "Play", "playLabel": "Play",
"@playLabel": {},
"playVideos": "Play videos", "playVideos": "Play videos",
"@playVideos": {},
"played": "Played", "played": "Played",
"@played": {},
"quickConnectAction": "Enter quick connect code for", "quickConnectAction": "Enter quick connect code for",
"@quickConnectAction": {},
"quickConnectInputACode": "Input a code", "quickConnectInputACode": "Input a code",
"@quickConnectInputACode": {},
"quickConnectTitle": "Quick-connect", "quickConnectTitle": "Quick-connect",
"@quickConnectTitle": {},
"quickConnectWrongCode": "Wrong code", "quickConnectWrongCode": "Wrong code",
"@quickConnectWrongCode": {},
"random": "Random", "random": "Random",
"@random": {},
"rating": "{count, plural, other{Ratings} one{Rating}}", "rating": "{count, plural, other{Ratings} one{Rating}}",
"@rating": { "@rating": {
"description": "rating", "description": "rating",
@ -392,6 +548,7 @@
} }
}, },
"reWatch": "Rewatch", "reWatch": "Rewatch",
"@reWatch": {},
"read": "Read {item}", "read": "Read {item}",
"@read": { "@read": {
"description": "read", "description": "read",
@ -411,8 +568,11 @@
} }
}, },
"recursive": "Recursive", "recursive": "Recursive",
"@recursive": {},
"refresh": "Refresh", "refresh": "Refresh",
"@refresh": {},
"refreshMetadata": "Refresh metadata", "refreshMetadata": "Refresh metadata",
"@refreshMetadata": {},
"refreshPopup": "Refresh - {name}", "refreshPopup": "Refresh - {name}",
"@refreshPopup": { "@refreshPopup": {
"placeholders": { "placeholders": {
@ -421,17 +581,28 @@
} }
} }
}, },
"refreshPopupContentMetadata": "Metadata is refreshed based on settings and internet services that are enabled in the Dashboard.", "refreshPopupContentMetadata": "Metadata is refreshed based on settings and Internet services turned on in the dashboard.",
"@refreshPopupContentMetadata": {},
"related": "Related", "related": "Related",
"@related": {},
"releaseDate": "Release date", "releaseDate": "Release date",
"@releaseDate": {},
"removeAsFavorite": "Remove as favorite", "removeAsFavorite": "Remove as favorite",
"removeFromCollection": "Remove to collection", "@removeAsFavorite": {},
"removeFromPlaylist": "Remove to playlist", "removeFromCollection": "Remove from collection",
"@removeFromCollection": {},
"removeFromPlaylist": "Remove from playlist",
"@removeFromPlaylist": {},
"replaceAllImages": "Replace all images", "replaceAllImages": "Replace all images",
"@replaceAllImages": {},
"replaceExistingImages": "Replace existing images", "replaceExistingImages": "Replace existing images",
"@replaceExistingImages": {},
"restart": "Restart", "restart": "Restart",
"@restart": {},
"result": "Result", "result": "Result",
"@result": {},
"resumable": "Resumable", "resumable": "Resumable",
"@resumable": {},
"resume": "Resume {item}", "resume": "Resume {item}",
"@resume": { "@resume": {
"description": "resume", "description": "resume",
@ -442,12 +613,19 @@
} }
}, },
"retrievePublicListOfUsers": "Retrieve public list of users", "retrievePublicListOfUsers": "Retrieve public list of users",
"@retrievePublicListOfUsers": {},
"retry": "Retry", "retry": "Retry",
"@retry": {},
"runTime": "Run time", "runTime": "Run time",
"@runTime": {},
"save": "Save", "save": "Save",
"@save": {},
"saved": "Saved", "saved": "Saved",
"@saved": {},
"scanBiometricHint": "Verify identity", "scanBiometricHint": "Verify identity",
"@scanBiometricHint": {},
"scanLibrary": "Scan library", "scanLibrary": "Scan library",
"@scanLibrary": {},
"scanYourFingerprintToAuthenticate": "Scan your fingerprint to authenticate {user}", "scanYourFingerprintToAuthenticate": "Scan your fingerprint to authenticate {user}",
"@scanYourFingerprintToAuthenticate": { "@scanYourFingerprintToAuthenticate": {
"placeholders": { "placeholders": {
@ -456,7 +634,7 @@
} }
} }
}, },
"scanningName": "Scanning - {name}", "scanningName": "Scanning - {name}",
"@scanningName": { "@scanningName": {
"placeholders": { "placeholders": {
"name": { "name": {
@ -465,7 +643,9 @@
} }
}, },
"scrollToTop": "Scroll to top", "scrollToTop": "Scroll to top",
"@scrollToTop": {},
"search": "Search", "search": "Search",
"@search": {},
"season": "{count, plural, other{Seasons} one{Season} }", "season": "{count, plural, other{Seasons} one{Season} }",
"@season": { "@season": {
"description": "season", "description": "season",
@ -487,9 +667,13 @@
} }
}, },
"selectAll": "Select all", "selectAll": "Select all",
"@selectAll": {},
"selectTime": "Select time", "selectTime": "Select time",
"@selectTime": {},
"selectViewType": "Select view type", "selectViewType": "Select view type",
"@selectViewType": {},
"selected": "Selected", "selected": "Selected",
"@selected": {},
"selectedWith": "Selected {info}", "selectedWith": "Selected {info}",
"@selectedWith": { "@selectedWith": {
"description": "selected", "description": "selected",
@ -500,7 +684,9 @@
} }
}, },
"separate": "Separate", "separate": "Separate",
"@separate": {},
"server": "Server", "server": "Server",
"@server": {},
"set": "Set", "set": "Set",
"@set": { "@set": {
"description": "Use for setting a certain value", "description": "Use for setting a certain value",
@ -516,50 +702,95 @@
} }
}, },
"settingSecurityApplockTitle": "App lock", "settingSecurityApplockTitle": "App lock",
"@settingSecurityApplockTitle": {},
"settings": "Settings", "settings": "Settings",
"@settings": {},
"settingsBlurEpisodesDesc": "Blur all upcoming episodes", "settingsBlurEpisodesDesc": "Blur all upcoming episodes",
"@settingsBlurEpisodesDesc": {},
"settingsBlurEpisodesTitle": "Blur next-up episodes", "settingsBlurEpisodesTitle": "Blur next-up episodes",
"@settingsBlurEpisodesTitle": {},
"settingsBlurredPlaceholderDesc": "Show blurred background when loading posters", "settingsBlurredPlaceholderDesc": "Show blurred background when loading posters",
"@settingsBlurredPlaceholderDesc": {},
"settingsBlurredPlaceholderTitle": "Blurred placeholder", "settingsBlurredPlaceholderTitle": "Blurred placeholder",
"@settingsBlurredPlaceholderTitle": {},
"settingsClientDesc": "General, Time-out, Layout, Theme", "settingsClientDesc": "General, Time-out, Layout, Theme",
"@settingsClientDesc": {},
"settingsClientTitle": "Fladder", "settingsClientTitle": "Fladder",
"@settingsClientTitle": {},
"settingsContinue": "Continue", "settingsContinue": "Continue",
"@settingsContinue": {},
"settingsEnableOsMediaControls": "Enable OS media controls", "settingsEnableOsMediaControls": "Enable OS media controls",
"settingsHomeCarouselDesc": "Shows a carousel on the dashboard screen", "@settingsEnableOsMediaControls": {},
"settingsHomeCarouselTitle": "Dashboard carousel", "settingsHomeBannerDescription": "Switch between a banner or scrollable carousel",
"settingsHomeBannerTitle": "Home banner",
"settingsHomeCarouselDesc": "Shows a banner on the dashboard screen",
"settingsHomeCarouselTitle": "Dashboard banner",
"settingsHomeNextUpDesc": "Type of posters shown in the dashboard screen", "settingsHomeNextUpDesc": "Type of posters shown in the dashboard screen",
"@settingsHomeNextUpDesc": {},
"settingsHomeNextUpTitle": "Next-up posters", "settingsHomeNextUpTitle": "Next-up posters",
"@settingsHomeNextUpTitle": {},
"settingsNextUpCutoffDays": "Next-up cutoff days", "settingsNextUpCutoffDays": "Next-up cutoff days",
"@settingsNextUpCutoffDays": {},
"settingsPlayerCustomSubtitlesDesc": "Customize Size, Color, Position, Outline", "settingsPlayerCustomSubtitlesDesc": "Customize Size, Color, Position, Outline",
"@settingsPlayerCustomSubtitlesDesc": {},
"settingsPlayerCustomSubtitlesTitle": "Customize subtitles", "settingsPlayerCustomSubtitlesTitle": "Customize subtitles",
"@settingsPlayerCustomSubtitlesTitle": {},
"settingsPlayerDesc": "Aspect-ratio, Advanced", "settingsPlayerDesc": "Aspect-ratio, Advanced",
"settingsPlayerMobileWarning": "Enabling Hardware acceleration and native libass subtitles on Android might cause some subtitles to not render.", "@settingsPlayerDesc": {},
"settingsPlayerMobileWarning": "Turning on hardware acceleration and native libass subtitles on Android might cause some subtitles to not render.",
"@settingsPlayerMobileWarning": {},
"settingsPlayerNativeLibassAccelDesc": "Use video player libass subtitle renderer", "settingsPlayerNativeLibassAccelDesc": "Use video player libass subtitle renderer",
"@settingsPlayerNativeLibassAccelDesc": {},
"settingsPlayerNativeLibassAccelTitle": "Native libass subtitles", "settingsPlayerNativeLibassAccelTitle": "Native libass subtitles",
"@settingsPlayerNativeLibassAccelTitle": {},
"settingsPlayerTitle": "Player", "settingsPlayerTitle": "Player",
"settingsPlayerVideoHWAccelDesc": "Use the gpu to render video (recommended)", "@settingsPlayerTitle": {},
"settingsPlayerVideoHWAccelDesc": "Use the GPU to render video (recommended)",
"@settingsPlayerVideoHWAccelDesc": {},
"settingsPlayerVideoHWAccelTitle": "Hardware acceleration", "settingsPlayerVideoHWAccelTitle": "Hardware acceleration",
"@settingsPlayerVideoHWAccelTitle": {},
"settingsPosterPinch": "Pinch-zoom to scale posters", "settingsPosterPinch": "Pinch-zoom to scale posters",
"@settingsPosterPinch": {},
"settingsPosterSize": "Poster size", "settingsPosterSize": "Poster size",
"@settingsPosterSize": {},
"settingsPosterSlider": "Show scale slider", "settingsPosterSlider": "Show scale slider",
"@settingsPosterSlider": {},
"settingsProfileDesc": "Lockscreen", "settingsProfileDesc": "Lockscreen",
"@settingsProfileDesc": {},
"settingsProfileTitle": "Profile", "settingsProfileTitle": "Profile",
"@settingsProfileTitle": {},
"settingsQuickConnectTitle": "Quick connect", "settingsQuickConnectTitle": "Quick connect",
"@settingsQuickConnectTitle": {},
"settingsSecurity": "Security", "settingsSecurity": "Security",
"@settingsSecurity": {},
"settingsShowScaleSlider": "Show poster size slide", "settingsShowScaleSlider": "Show poster size slide",
"@settingsShowScaleSlider": {},
"settingsVisual": "Visual", "settingsVisual": "Visual",
"@settingsVisual": {},
"shadow": "Shadow", "shadow": "Shadow",
"@shadow": {},
"showAlbum": "Show album", "showAlbum": "Show album",
"@showAlbum": {},
"showDetails": "Show details", "showDetails": "Show details",
"@showDetails": {},
"showEmpty": "Show empty", "showEmpty": "Show empty",
"@showEmpty": {},
"shuffleGallery": "Shuffle gallery", "shuffleGallery": "Shuffle gallery",
"@shuffleGallery": {},
"shuffleVideos": "Shuffle videos", "shuffleVideos": "Shuffle videos",
"@shuffleVideos": {},
"somethingWentWrong": "Something went wrong", "somethingWentWrong": "Something went wrong",
"somethingWentWrongPasswordCheck": "Something went wrong, check your password", "@somethingWentWrong": {},
"somethingWentWrongPasswordCheck": "Something went wrong. Check your password.",
"@somethingWentWrongPasswordCheck": {},
"sortBy": "Sort by", "sortBy": "Sort by",
"@sortBy": {},
"sortName": "Name", "sortName": "Name",
"@sortName": {},
"sortOrder": "Sort order", "sortOrder": "Sort order",
"@sortOrder": {},
"start": "Start", "start": "Start",
"@start": {},
"studio": "{count, plural, other{Studios} one{Studio}}", "studio": "{count, plural, other{Studios} one{Studio}}",
"@studio": { "@studio": {
"description": "studio", "description": "studio",
@ -571,10 +802,15 @@
} }
}, },
"subtitleConfigurator": "Subtitle configurator", "subtitleConfigurator": "Subtitle configurator",
"@subtitleConfigurator": {},
"subtitleConfiguratorPlaceHolder": "This is placeholder text, \n nothing to see here.", "subtitleConfiguratorPlaceHolder": "This is placeholder text, \n nothing to see here.",
"@subtitleConfiguratorPlaceHolder": {},
"subtitles": "Subtitles", "subtitles": "Subtitles",
"@subtitles": {},
"switchUser": "Switch user", "switchUser": "Switch user",
"@switchUser": {},
"sync": "Sync", "sync": "Sync",
"@sync": {},
"syncDeleteItemDesc": "Delete all synced data for?\n{item}", "syncDeleteItemDesc": "Delete all synced data for?\n{item}",
"@syncDeleteItemDesc": { "@syncDeleteItemDesc": {
"description": "Sync delete item pop-up window", "description": "Sync delete item pop-up window",
@ -585,12 +821,19 @@
} }
}, },
"syncDeleteItemTitle": "Delete synced item", "syncDeleteItemTitle": "Delete synced item",
"@syncDeleteItemTitle": {},
"syncDeletePopupPermanent": "This action is permanent and will remove all localy synced files", "syncDeletePopupPermanent": "This action is permanent and will remove all localy synced files",
"@syncDeletePopupPermanent": {},
"syncDetails": "Sync details", "syncDetails": "Sync details",
"@syncDetails": {},
"syncOpenParent": "Open parent", "syncOpenParent": "Open parent",
"@syncOpenParent": {},
"syncRemoveDataDesc": "Delete synced video data? This is permanent and you will need to re-sync the files", "syncRemoveDataDesc": "Delete synced video data? This is permanent and you will need to re-sync the files",
"@syncRemoveDataDesc": {},
"syncRemoveDataTitle": "Remove synced data?", "syncRemoveDataTitle": "Remove synced data?",
"@syncRemoveDataTitle": {},
"syncedItems": "Synced items", "syncedItems": "Synced items",
"@syncedItems": {},
"tag": "{count, plural, one{Tag} other{Tags}}", "tag": "{count, plural, one{Tag} other{Tags}}",
"@tag": { "@tag": {
"description": "tag", "description": "tag",
@ -602,10 +845,15 @@
} }
}, },
"theme": "Theme", "theme": "Theme",
"@theme": {},
"themeColor": "Theme color", "themeColor": "Theme color",
"@themeColor": {},
"themeModeDark": "Dark", "themeModeDark": "Dark",
"@themeModeDark": {},
"themeModeLight": "Light", "themeModeLight": "Light",
"@themeModeLight": {},
"themeModeSystem": "System", "themeModeSystem": "System",
"@themeModeSystem": {},
"timeAndAnnotation": "{minutes} and {seconds}", "timeAndAnnotation": "{minutes} and {seconds}",
"@timeAndAnnotation": { "@timeAndAnnotation": {
"description": "timeAndAnnotation", "description": "timeAndAnnotation",
@ -619,6 +867,7 @@
} }
}, },
"timeOut": "Time-out", "timeOut": "Time-out",
"@timeOut": {},
"totalSize": "Total size: {size}", "totalSize": "Total size: {size}",
"@totalSize": { "@totalSize": {
"placeholders": { "placeholders": {
@ -638,24 +887,43 @@
} }
}, },
"unPlayed": "Unplayed", "unPlayed": "Unplayed",
"@unPlayed": {},
"unableToConnectHost": "Unable to connect to host", "unableToConnectHost": "Unable to connect to host",
"unableToReverseAction": "This action can not be reversed, it will remove all settings.", "@unableToConnectHost": {},
"unableToReverseAction": "This action can not be reversed. It will remove all settings.",
"@unableToReverseAction": {},
"unknown": "Unknown", "unknown": "Unknown",
"@unknown": {},
"useDefaults": "Use defaults", "useDefaults": "Use defaults",
"@useDefaults": {},
"userName": "Username", "userName": "Username",
"@userName": {},
"video": "Video", "video": "Video",
"@video": {},
"videoScaling": "Video scaling", "videoScaling": "Video scaling",
"@videoScaling": {},
"videoScalingContain": "Contain", "videoScalingContain": "Contain",
"@videoScalingContain": {},
"videoScalingCover": "Cover", "videoScalingCover": "Cover",
"@videoScalingCover": {},
"videoScalingFill": "Fill", "videoScalingFill": "Fill",
"@videoScalingFill": {},
"videoScalingFillScreenDesc": "Fill the navigation and statusbar", "videoScalingFillScreenDesc": "Fill the navigation and statusbar",
"@videoScalingFillScreenDesc": {},
"videoScalingFillScreenNotif": "Fill-screen overwrites video fit, in horizontal rotation", "videoScalingFillScreenNotif": "Fill-screen overwrites video fit, in horizontal rotation",
"@videoScalingFillScreenNotif": {},
"videoScalingFillScreenTitle": "Fill screen", "videoScalingFillScreenTitle": "Fill screen",
"@videoScalingFillScreenTitle": {},
"videoScalingFitHeight": "Fit Height", "videoScalingFitHeight": "Fit Height",
"@videoScalingFitHeight": {},
"videoScalingFitWidth": "Fit Width", "videoScalingFitWidth": "Fit Width",
"@videoScalingFitWidth": {},
"videoScalingScaleDown": "ScaleDown", "videoScalingScaleDown": "ScaleDown",
"@videoScalingScaleDown": {},
"viewPhotos": "View photos", "viewPhotos": "View photos",
"@viewPhotos": {},
"watchOn": "Watch on", "watchOn": "Watch on",
"@watchOn": {},
"writer": "{count, plural, other{Writer} two{Writers}}", "writer": "{count, plural, other{Writer} two{Writers}}",
"@writer": { "@writer": {
"description": "writer", "description": "writer",

View file

@ -1 +1,41 @@
{} {
"es": [
"nativeName",
"settingsHomeBannerTitle",
"settingsHomeBannerDescription",
"homeBannerBanner",
"homeBannerCarousel"
],
"fr": [
"nativeName",
"settingsHomeBannerTitle",
"settingsHomeBannerDescription",
"homeBannerBanner",
"homeBannerCarousel"
],
"ja": [
"nativeName",
"settingsHomeBannerTitle",
"settingsHomeBannerDescription",
"homeBannerBanner",
"homeBannerCarousel"
],
"nl": [
"nativeName",
"settingsHomeBannerTitle",
"settingsHomeBannerDescription",
"homeBannerBanner",
"homeBannerCarousel"
],
"zh": [
"nativeName",
"settingsHomeBannerTitle",
"settingsHomeBannerDescription",
"homeBannerBanner",
"homeBannerCarousel"
]
}

View file

@ -4,10 +4,11 @@ import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fladder/util/custom_color_themes.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:fladder/util/custom_color_themes.dart';
import 'package:fladder/util/localization_helper.dart';
part 'client_settings_model.freezed.dart'; part 'client_settings_model.freezed.dart';
part 'client_settings_model.g.dart'; part 'client_settings_model.g.dart';
@ -22,6 +23,7 @@ class ClientSettingsModel with _$ClientSettingsModel {
Duration? nextUpDateCutoff, Duration? nextUpDateCutoff,
@Default(ThemeMode.system) ThemeMode themeMode, @Default(ThemeMode.system) ThemeMode themeMode,
ColorThemes? themeColor, ColorThemes? themeColor,
@Default(HomeBanner.carousel) HomeBanner homeBanner,
@Default(false) bool amoledBlack, @Default(false) bool amoledBlack,
@Default(false) bool blurPlaceHolders, @Default(false) bool blurPlaceHolders,
@Default(false) bool blurUpcomingEpisodes, @Default(false) bool blurUpcomingEpisodes,
@ -71,6 +73,18 @@ class LocaleConvert implements JsonConverter<Locale?, String?> {
} }
} }
enum HomeBanner {
carousel,
banner;
const HomeBanner();
String label(BuildContext context) => switch (this) {
HomeBanner.carousel => context.localized.homeBannerCarousel,
HomeBanner.banner => context.localized.homeBannerBanner,
};
}
class Vector2 { class Vector2 {
final double x; final double x;
final double y; final double y;

View file

@ -27,6 +27,7 @@ mixin _$ClientSettingsModel {
Duration? get nextUpDateCutoff => throw _privateConstructorUsedError; Duration? get nextUpDateCutoff => throw _privateConstructorUsedError;
ThemeMode get themeMode => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError;
ColorThemes? get themeColor => throw _privateConstructorUsedError; ColorThemes? get themeColor => throw _privateConstructorUsedError;
HomeBanner get homeBanner => throw _privateConstructorUsedError;
bool get amoledBlack => throw _privateConstructorUsedError; bool get amoledBlack => throw _privateConstructorUsedError;
bool get blurPlaceHolders => throw _privateConstructorUsedError; bool get blurPlaceHolders => throw _privateConstructorUsedError;
bool get blurUpcomingEpisodes => throw _privateConstructorUsedError; bool get blurUpcomingEpisodes => throw _privateConstructorUsedError;
@ -38,8 +39,12 @@ mixin _$ClientSettingsModel {
bool get mouseDragSupport => throw _privateConstructorUsedError; bool get mouseDragSupport => throw _privateConstructorUsedError;
int? get libraryPageSize => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError;
/// Serializes this ClientSettingsModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
/// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ClientSettingsModelCopyWith<ClientSettingsModel> get copyWith => $ClientSettingsModelCopyWith<ClientSettingsModel> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
@ -58,6 +63,7 @@ abstract class $ClientSettingsModelCopyWith<$Res> {
Duration? nextUpDateCutoff, Duration? nextUpDateCutoff,
ThemeMode themeMode, ThemeMode themeMode,
ColorThemes? themeColor, ColorThemes? themeColor,
HomeBanner homeBanner,
bool amoledBlack, bool amoledBlack,
bool blurPlaceHolders, bool blurPlaceHolders,
bool blurUpcomingEpisodes, bool blurUpcomingEpisodes,
@ -79,6 +85,8 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
// ignore: unused_field // ignore: unused_field
final $Res Function($Val) _then; final $Res Function($Val) _then;
/// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
@ -89,6 +97,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
Object? nextUpDateCutoff = freezed, Object? nextUpDateCutoff = freezed,
Object? themeMode = null, Object? themeMode = null,
Object? themeColor = freezed, Object? themeColor = freezed,
Object? homeBanner = null,
Object? amoledBlack = null, Object? amoledBlack = null,
Object? blurPlaceHolders = null, Object? blurPlaceHolders = null,
Object? blurUpcomingEpisodes = null, Object? blurUpcomingEpisodes = null,
@ -128,6 +137,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
? _value.themeColor ? _value.themeColor
: themeColor // ignore: cast_nullable_to_non_nullable : themeColor // ignore: cast_nullable_to_non_nullable
as ColorThemes?, as ColorThemes?,
homeBanner: null == homeBanner
? _value.homeBanner
: homeBanner // ignore: cast_nullable_to_non_nullable
as HomeBanner,
amoledBlack: null == amoledBlack amoledBlack: null == amoledBlack
? _value.amoledBlack ? _value.amoledBlack
: amoledBlack // ignore: cast_nullable_to_non_nullable : amoledBlack // ignore: cast_nullable_to_non_nullable
@ -184,6 +197,7 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res>
Duration? nextUpDateCutoff, Duration? nextUpDateCutoff,
ThemeMode themeMode, ThemeMode themeMode,
ColorThemes? themeColor, ColorThemes? themeColor,
HomeBanner homeBanner,
bool amoledBlack, bool amoledBlack,
bool blurPlaceHolders, bool blurPlaceHolders,
bool blurUpcomingEpisodes, bool blurUpcomingEpisodes,
@ -203,6 +217,8 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
$Res Function(_$ClientSettingsModelImpl) _then) $Res Function(_$ClientSettingsModelImpl) _then)
: super(_value, _then); : super(_value, _then);
/// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
@override @override
$Res call({ $Res call({
@ -213,6 +229,7 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
Object? nextUpDateCutoff = freezed, Object? nextUpDateCutoff = freezed,
Object? themeMode = null, Object? themeMode = null,
Object? themeColor = freezed, Object? themeColor = freezed,
Object? homeBanner = null,
Object? amoledBlack = null, Object? amoledBlack = null,
Object? blurPlaceHolders = null, Object? blurPlaceHolders = null,
Object? blurUpcomingEpisodes = null, Object? blurUpcomingEpisodes = null,
@ -252,6 +269,10 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
? _value.themeColor ? _value.themeColor
: themeColor // ignore: cast_nullable_to_non_nullable : themeColor // ignore: cast_nullable_to_non_nullable
as ColorThemes?, as ColorThemes?,
homeBanner: null == homeBanner
? _value.homeBanner
: homeBanner // ignore: cast_nullable_to_non_nullable
as HomeBanner,
amoledBlack: null == amoledBlack amoledBlack: null == amoledBlack
? _value.amoledBlack ? _value.amoledBlack
: amoledBlack // ignore: cast_nullable_to_non_nullable : amoledBlack // ignore: cast_nullable_to_non_nullable
@ -304,6 +325,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
this.nextUpDateCutoff, this.nextUpDateCutoff,
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
this.themeColor, this.themeColor,
this.homeBanner = HomeBanner.carousel,
this.amoledBlack = false, this.amoledBlack = false,
this.blurPlaceHolders = false, this.blurPlaceHolders = false,
this.blurUpcomingEpisodes = false, this.blurUpcomingEpisodes = false,
@ -338,6 +360,9 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
final ColorThemes? themeColor; final ColorThemes? themeColor;
@override @override
@JsonKey() @JsonKey()
final HomeBanner homeBanner;
@override
@JsonKey()
final bool amoledBlack; final bool amoledBlack;
@override @override
@JsonKey() @JsonKey()
@ -365,7 +390,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, libraryPageSize: $libraryPageSize)'; return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, homeBanner: $homeBanner, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, libraryPageSize: $libraryPageSize)';
} }
@override @override
@ -380,6 +405,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
..add(DiagnosticsProperty('nextUpDateCutoff', nextUpDateCutoff)) ..add(DiagnosticsProperty('nextUpDateCutoff', nextUpDateCutoff))
..add(DiagnosticsProperty('themeMode', themeMode)) ..add(DiagnosticsProperty('themeMode', themeMode))
..add(DiagnosticsProperty('themeColor', themeColor)) ..add(DiagnosticsProperty('themeColor', themeColor))
..add(DiagnosticsProperty('homeBanner', homeBanner))
..add(DiagnosticsProperty('amoledBlack', amoledBlack)) ..add(DiagnosticsProperty('amoledBlack', amoledBlack))
..add(DiagnosticsProperty('blurPlaceHolders', blurPlaceHolders)) ..add(DiagnosticsProperty('blurPlaceHolders', blurPlaceHolders))
..add(DiagnosticsProperty('blurUpcomingEpisodes', blurUpcomingEpisodes)) ..add(DiagnosticsProperty('blurUpcomingEpisodes', blurUpcomingEpisodes))
@ -408,6 +434,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
other.themeMode == themeMode) && other.themeMode == themeMode) &&
(identical(other.themeColor, themeColor) || (identical(other.themeColor, themeColor) ||
other.themeColor == themeColor) && other.themeColor == themeColor) &&
(identical(other.homeBanner, homeBanner) ||
other.homeBanner == homeBanner) &&
(identical(other.amoledBlack, amoledBlack) || (identical(other.amoledBlack, amoledBlack) ||
other.amoledBlack == amoledBlack) && other.amoledBlack == amoledBlack) &&
(identical(other.blurPlaceHolders, blurPlaceHolders) || (identical(other.blurPlaceHolders, blurPlaceHolders) ||
@ -428,7 +456,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
other.libraryPageSize == libraryPageSize)); other.libraryPageSize == libraryPageSize));
} }
@JsonKey(ignore: true) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hash(
runtimeType, runtimeType,
@ -439,6 +467,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
nextUpDateCutoff, nextUpDateCutoff,
themeMode, themeMode,
themeColor, themeColor,
homeBanner,
amoledBlack, amoledBlack,
blurPlaceHolders, blurPlaceHolders,
blurUpcomingEpisodes, blurUpcomingEpisodes,
@ -449,7 +478,9 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
mouseDragSupport, mouseDragSupport,
libraryPageSize); libraryPageSize);
@JsonKey(ignore: true) /// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
_$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith => _$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith =>
@ -473,6 +504,7 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
final Duration? nextUpDateCutoff, final Duration? nextUpDateCutoff,
final ThemeMode themeMode, final ThemeMode themeMode,
final ColorThemes? themeColor, final ColorThemes? themeColor,
final HomeBanner homeBanner,
final bool amoledBlack, final bool amoledBlack,
final bool blurPlaceHolders, final bool blurPlaceHolders,
final bool blurUpcomingEpisodes, final bool blurUpcomingEpisodes,
@ -502,6 +534,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
@override @override
ColorThemes? get themeColor; ColorThemes? get themeColor;
@override @override
HomeBanner get homeBanner;
@override
bool get amoledBlack; bool get amoledBlack;
@override @override
bool get blurPlaceHolders; bool get blurPlaceHolders;
@ -520,8 +554,11 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
bool get mouseDragSupport; bool get mouseDragSupport;
@override @override
int? get libraryPageSize; int? get libraryPageSize;
/// Create a copy of ClientSettingsModel
/// with the given fields replaced by the non-null parameter values.
@override @override
@JsonKey(ignore: true) @JsonKey(includeFromJson: false, includeToJson: false)
_$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith => _$$ClientSettingsModelImplCopyWith<_$ClientSettingsModelImpl> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }

View file

@ -25,6 +25,9 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson(
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
ThemeMode.system, ThemeMode.system,
themeColor: $enumDecodeNullable(_$ColorThemesEnumMap, json['themeColor']), themeColor: $enumDecodeNullable(_$ColorThemesEnumMap, json['themeColor']),
homeBanner:
$enumDecodeNullable(_$HomeBannerEnumMap, json['homeBanner']) ??
HomeBanner.carousel,
amoledBlack: json['amoledBlack'] as bool? ?? false, amoledBlack: json['amoledBlack'] as bool? ?? false,
blurPlaceHolders: json['blurPlaceHolders'] as bool? ?? false, blurPlaceHolders: json['blurPlaceHolders'] as bool? ?? false,
blurUpcomingEpisodes: json['blurUpcomingEpisodes'] as bool? ?? false, blurUpcomingEpisodes: json['blurUpcomingEpisodes'] as bool? ?? false,
@ -47,6 +50,7 @@ Map<String, dynamic> _$$ClientSettingsModelImplToJson(
'nextUpDateCutoff': instance.nextUpDateCutoff?.inMicroseconds, 'nextUpDateCutoff': instance.nextUpDateCutoff?.inMicroseconds,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'themeColor': _$ColorThemesEnumMap[instance.themeColor], 'themeColor': _$ColorThemesEnumMap[instance.themeColor],
'homeBanner': _$HomeBannerEnumMap[instance.homeBanner]!,
'amoledBlack': instance.amoledBlack, 'amoledBlack': instance.amoledBlack,
'blurPlaceHolders': instance.blurPlaceHolders, 'blurPlaceHolders': instance.blurPlaceHolders,
'blurUpcomingEpisodes': instance.blurUpcomingEpisodes, 'blurUpcomingEpisodes': instance.blurUpcomingEpisodes,
@ -81,3 +85,8 @@ const _$ColorThemesEnumMap = {
ColorThemes.deepPurple: 'deepPurple', ColorThemes.deepPurple: 'deepPurple',
ColorThemes.blueGrey: 'blueGrey', ColorThemes.blueGrey: 'blueGrey',
}; };
const _$HomeBannerEnumMap = {
HomeBanner.carousel: 'carousel',
HomeBanner.banner: 'banner',
};

View file

@ -16,7 +16,7 @@ import 'package:fladder/providers/settings/home_settings_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart'; import 'package:fladder/screens/dashboard/top_posters_row.dart';
import 'package:fladder/screens/shared/media/poster_row.dart'; import 'package:fladder/screens/shared/media/poster_row.dart';
import 'package:fladder/screens/shared/nested_scaffold.dart'; import 'package:fladder/screens/shared/nested_scaffold.dart';
import 'package:fladder/screens/shared/nested_sliver_appbar.dart'; import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
@ -104,20 +104,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: Transform.translate( child: Transform.translate(
offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0), offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0),
child: ConstrainedBox( child: TopPostersRow(posters: homeCarouselItems),
constraints: BoxConstraints(
minHeight: AdaptiveLayout.of(context).isDesktop ? 350 : 275,
maxHeight: (MediaQuery.sizeOf(context).height * 0.25).clamp(400, double.infinity)),
child: AspectRatio(
aspectRatio: 1.6,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: CarouselBanner(
items: homeCarouselItems,
),
),
),
),
), ),
), ),
} else if (AdaptiveLayout.of(context).isDesktop) } else if (AdaptiveLayout.of(context).isDesktop)

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/screens/shared/media/carousel_banner.dart';
import 'package:fladder/screens/shared/media/media_banner.dart';
class TopPostersRow extends ConsumerWidget {
final List<ItemBaseModel> posters;
const TopPostersRow({required this.posters, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bannerType = ref.watch(clientSettingsProvider.select((value) => value.homeBanner));
final maxHeight = (MediaQuery.sizeOf(context).shortestSide * 0.6).clamp(125.0, 350.0);
return switch (bannerType) {
HomeBanner.carousel => Column(
mainAxisSize: MainAxisSize.min,
children: [
CarouselBanner(
items: posters,
maxHeight: maxHeight,
),
const SizedBox(height: 8)
],
),
HomeBanner.banner => MediaBanner(
items: posters,
maxHeight: maxHeight,
)
};
}
}

View file

@ -7,6 +7,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/settings/client_settings_model.dart';
import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/models/settings/home_settings_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart'; import 'package:fladder/providers/settings/home_settings_provider.dart';
@ -186,6 +187,28 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
.toList(), .toList(),
), ),
), ),
SettingsListTile(
label: Text(context.localized.settingsHomeBannerTitle),
subLabel: Text(context.localized.settingsHomeBannerDescription),
trailing: EnumBox(
current: ref.watch(
clientSettingsProvider.select(
(value) => value.homeBanner.label(context),
),
),
itemBuilder: (context) => HomeBanner.values
.map(
(entry) => PopupMenuItem(
value: entry,
child: Text(entry.label(context)),
onTap: () => ref
.read(clientSettingsProvider.notifier)
.update((context) => context.copyWith(homeBanner: entry)),
),
)
.toList(),
),
),
SettingsListTile( SettingsListTile(
label: Text(context.localized.settingsHomeNextUpTitle), label: Text(context.localized.settingsHomeNextUpTitle),
subLabel: Text(context.localized.settingsHomeNextUpDesc), subLabel: Text(context.localized.settingsHomeNextUpDesc),

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
@ -45,7 +44,7 @@ class SettingsScaffold extends ConsumerWidget {
leading: context.router.backButton(), leading: context.router.backButton(),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.symmetric(horizontal: 16) titlePadding: const EdgeInsets.symmetric(horizontal: 16)
.add(EdgeInsets.only(left: padding.left, right: padding.right)), .add(EdgeInsets.only(left: padding.left, right: padding.right, bottom: 4)),
title: Row( title: Row(
children: [ children: [
Text(label, style: Theme.of(context).textTheme.headlineLarge), Text(label, style: Theme.of(context).textTheme.headlineLarge),
@ -75,8 +74,7 @@ class SettingsScaffold extends ConsumerWidget {
), ),
), ),
SliverPadding( SliverPadding(
padding: MediaQuery.paddingOf(context) padding: MediaQuery.paddingOf(context).copyWith(top: AdaptiveLayout.of(context).isDesktop ? 0 : 8),
.copyWith(top: AdaptiveLayout.of(context).isDesktop || kIsWeb ? 0 : null),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate(items), delegate: SliverChildListDelegate(items),
), ),

View file

@ -1,26 +1,25 @@
import 'package:async/async.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/screens/shared/media/components/media_play_button.dart';
import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/themes_data.dart'; import 'package:fladder/widgets/shared/fladder_carousel.dart';
import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CarouselBanner extends ConsumerStatefulWidget { class CarouselBanner extends ConsumerStatefulWidget {
final PageController? controller; final PageController? controller;
final List<ItemBaseModel> items; final List<ItemBaseModel> items;
final double maxHeight;
const CarouselBanner({ const CarouselBanner({
this.controller, this.controller,
required this.items, required this.items,
this.maxHeight = 250,
super.key, super.key,
}); });
@ -29,350 +28,120 @@ class CarouselBanner extends ConsumerStatefulWidget {
} }
class _CarouselBannerState extends ConsumerState<CarouselBanner> { class _CarouselBannerState extends ConsumerState<CarouselBanner> {
bool showControls = false;
bool interacting = false;
int currentPage = 0;
double dragOffset = 0;
double dragIntensity = 1;
double slidePosition = 1;
late final RestartableTimer timer = RestartableTimer(const Duration(seconds: 8), () => nextSlide());
@override
void initState() {
super.initState();
timer.reset();
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
void nextSlide() {
if (!interacting) {
setState(() {
if (currentPage == widget.items.length - 1) {
currentPage = 0;
} else {
currentPage++;
}
});
}
timer.reset();
}
void previousSlide() {
if (!interacting) {
setState(() {
if (currentPage == 0) {
currentPage = widget.items.length - 1;
} else {
currentPage--;
}
});
}
timer.reset();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary; return ConstrainedBox(
final shadows = [ constraints: BoxConstraints(maxHeight: widget.maxHeight),
BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor), child: LayoutBuilder(
]; builder: (context, constraints) {
final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)]; final maxExtent = (constraints.maxHeight * 2.1).clamp(250.0, MediaQuery.sizeOf(context).shortestSide * 0.75);
final actions = currentItem.generateActions(context, ref); final border = BorderRadius.circular(18);
return FladderCarousel(
final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1); shape: RoundedRectangleBorder(borderRadius: border),
onTap: (index) => widget.items[index].navigateTo(context),
return Column( onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
mainAxisSize: MainAxisSize.min, ? null
children: [ : (index) {
Flexible( final poster = widget.items[index];
child: Card( showBottomSheetPill(
elevation: 16, context: context,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), item: poster,
surfaceTintColor: overlayColor, content: (scrollContext, scrollController) => ListView(
color: overlayColor, shrinkWrap: true,
child: GestureDetector( controller: scrollController,
onTap: () => currentItem.navigateTo(context), children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () async {
interacting = true;
await showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
controller: scrollController,
shrinkWrap: true,
children: actions.listTileItems(context, useIcons: true),
),
);
interacting = false;
timer.reset();
}
: null,
child: MouseRegion(
onEnter: (event) => setState(() => showControls = true),
onHover: (event) => timer.reset(),
onExit: (event) => setState(() => showControls = false),
child: Stack(
fit: StackFit.expand,
children: [
Dismissible(
key: const Key("Dismissable"),
direction: DismissDirection.horizontal,
onUpdate: (details) {
setState(() {
dragOffset = details.progress * 4;
});
},
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
previousSlide();
} else {
nextSlide();
}
return false;
},
child: AnimatedOpacity(
duration: const Duration(milliseconds: 125),
opacity: dragOpacity.abs(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 125),
child: Container(
key: Key(currentItem.id),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside),
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
overlayColor.withOpacity(1),
overlayColor.withOpacity(0.75),
overlayColor.withOpacity(0.45),
overlayColor.withOpacity(0.15),
overlayColor.withOpacity(0),
overlayColor.withOpacity(0),
overlayColor.withOpacity(0.1),
],
),
),
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
),
),
),
),
), ),
), );
Row( },
children: [ onSecondaryTap: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
Expanded( ? null
child: Padding( : (details) async {
padding: const EdgeInsets.all(16), Offset localPosition = details.$2.globalPosition;
child: Column( RelativeRect position = RelativeRect.fromLTRB(
mainAxisAlignment: MainAxisAlignment.end, localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
crossAxisAlignment: CrossAxisAlignment.start, final poster = widget.items[details.$1];
children: [
Flexible( await showMenu(
child: IgnorePointer( context: context,
child: Column( position: position,
crossAxisAlignment: CrossAxisAlignment.start, items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
mainAxisSize: MainAxisSize.min, );
children: [ },
Flexible( itemExtent: maxExtent,
child: Text( children: [
currentItem.title, ...widget.items.mapIndexed(
maxLines: 3, (index, e) => LayoutBuilder(builder: (context, constraints) {
style: Theme.of(context).textTheme.displaySmall?.copyWith( final opacity = (constraints.maxWidth / maxExtent);
shadows: shadows, return Stack(
color: Colors.white, clipBehavior: Clip.none,
), children: [
), FladderImage(image: e.bannerImage),
), AnimatedOpacity(
if (currentItem.label(context) != null && currentItem is! MovieModel) duration: const Duration(milliseconds: 250),
Flexible( opacity: opacity.clamp(0, 1),
child: Text( child: Stack(
currentItem.label(context)!, children: [
maxLines: 3, Positioned.fill(
style: Theme.of(context).textTheme.titleLarge?.copyWith( child: Container(
shadows: shadows, decoration: BoxDecoration(
color: Colors.white.withOpacity(0.75), gradient: LinearGradient(
), begin: Alignment.bottomLeft,
), end: Alignment.topCenter,
), colors: [
if (currentItem.overview.summary.isNotEmpty && Theme.of(context).colorScheme.primaryContainer.withOpacity(0.75),
AdaptiveLayout.layoutOf(context) != LayoutState.phone) Colors.transparent,
Flexible( ],
child: Text(
currentItem.overview.summary,
maxLines: 3,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
shadows: shadows,
color: Colors.white.withOpacity(0.75),
),
),
),
].addInBetween(const SizedBox(height: 6)),
),
), ),
), ),
Wrap( ),
runSpacing: 6,
spacing: 6,
children: [
if (currentItem.playAble)
MediaPlayButton(
item: currentItem,
onPressed: () async {
await currentItem.play(
context,
ref,
);
},
),
],
),
].addInBetween(const SizedBox(height: 16)),
), ),
), ],
), ),
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: () => nextSlide(),
icon: const Icon(IconsaxOutline.arrow_right_3),
)
],
),
),
),
],
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
Align( Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomLeft,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16.0),
child: Card( child: Column(
child: PopupMenuButton( mainAxisSize: MainAxisSize.min,
onOpened: () => interacting = true, crossAxisAlignment: CrossAxisAlignment.start,
onCanceled: () { children: [
interacting = false; Text(
timer.reset(); e.title,
}, maxLines: 2,
itemBuilder: (context) => actions.popupMenuItems(useIcons: true), softWrap: e.title.length > 25,
), textWidthBasis: TextWidthBasis.parent,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(color: Colors.white),
),
if (e.label(context) != null)
Text(
e.label(context)!,
maxLines: 2,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white),
),
].addInBetween(const SizedBox(height: 4)),
), ),
), ),
), ),
], Container(
), decoration: BoxDecoration(
), border: Border.all(
), color: Colors.white.withOpacity(0.1),
), width: 1.0,
),
GestureDetector(
onHorizontalDragUpdate: (details) {
final delta = (details.primaryDelta ?? 0) / 20;
slidePosition += delta;
if (slidePosition > 1) {
nextSlide();
slidePosition = 0;
} else if (slidePosition < -1) {
previousSlide();
slidePosition = 0;
}
},
onHorizontalDragStart: (details) {
slidePosition = 0;
},
child: Container(
color: Colors.black.withOpacity(0),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: widget.items.mapIndexed((index, e) {
return Tooltip(
message: '${e.name}\n${e.detailedName}',
child: Card(
elevation: 0,
color: Colors.transparent,
child: InkWell(
onTapUp: currentPage == index
? null
: (details) {
animateToTarget(index);
timer.reset();
},
child: Container(
alignment: Alignment.center,
color: Colors.red.withOpacity(0),
width: 28,
height: 28,
child: AnimatedContainer(
duration: const Duration(milliseconds: 125),
width: currentItem == e ? 22 : 6,
height: currentItem == e ? 10 : 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: currentItem == e
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary.withOpacity(0.25),
), ),
), borderRadius: border),
),
), ),
), ],
); );
}).toList(), }),
), )
), ],
), );
) },
], ),
); );
} }
void animateToTarget(int nextIndex) {
int step = currentPage < nextIndex ? 1 : -1;
void updateItem(int item) {
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
setState(() {
currentPage = item;
});
if (currentPage != nextIndex) {
updateItem(item + step);
}
});
timer.reset();
}
updateItem(currentPage + step);
}
} }

View file

@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:ficonsax/ficonsax.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/util/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/item_base_model/item_base_model_extensions.dart';
import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/themes_data.dart';
import 'package:fladder/widgets/shared/fladder_carousel.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
class MediaBanner extends ConsumerStatefulWidget {
final PageController? controller;
final List<ItemBaseModel> items;
final double maxHeight;
const MediaBanner({
this.controller,
required this.items,
this.maxHeight = 250,
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _MediaBannerState();
}
class _MediaBannerState extends ConsumerState<MediaBanner> {
bool showControls = false;
bool interacting = false;
int currentPage = 0;
double dragOffset = 0;
double dragIntensity = 1;
double slidePosition = 1;
late final RestartableTimer timer = RestartableTimer(const Duration(seconds: 8), () => nextSlide());
@override
void initState() {
super.initState();
timer.reset();
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
void nextSlide() {
if (!interacting) {
setState(() {
if (currentPage == widget.items.length - 1) {
currentPage = 0;
} else {
currentPage++;
}
});
}
timer.reset();
}
void previousSlide() {
if (!interacting) {
setState(() {
if (currentPage == 0) {
currentPage = widget.items.length - 1;
} else {
currentPage--;
}
});
}
timer.reset();
}
final controller = FladderCarouselController();
@override
Widget build(BuildContext context) {
final overlayColor = ThemesData.of(context).dark.colorScheme.onSecondary;
final shadows = [
BoxShadow(blurRadius: 12, spreadRadius: 8, color: overlayColor),
];
final currentItem = widget.items[currentPage.clamp(0, widget.items.length - 1)];
final actions = currentItem.generateActions(context, ref);
final double dragOpacity = (1 - dragOffset.abs()).clamp(0, 1);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
child: Card(
elevation: 16,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
surfaceTintColor: overlayColor,
color: overlayColor,
child: GestureDetector(
onTap: () => currentItem.navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? () async {
interacting = true;
final poster = currentItem;
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true),
),
);
interacting = false;
timer.reset();
}
: null,
child: MouseRegion(
onEnter: (event) => setState(() => showControls = true),
onHover: (event) => timer.reset(),
onExit: (event) => setState(() => showControls = false),
child: Stack(
fit: StackFit.expand,
children: [
Dismissible(
key: const Key("Dismissable"),
direction: DismissDirection.horizontal,
onUpdate: (details) {
setState(() {
dragOffset = details.progress * 4;
});
},
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
previousSlide();
} else {
nextSlide();
}
return false;
},
child: AnimatedOpacity(
duration: const Duration(milliseconds: 125),
opacity: dragOpacity.abs(),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 125),
child: Container(
key: Key(currentItem.id),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withOpacity(0.10), strokeAlign: BorderSide.strokeAlignInside),
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
overlayColor.withOpacity(1),
overlayColor.withOpacity(0.75),
overlayColor.withOpacity(0.45),
overlayColor.withOpacity(0.15),
overlayColor.withOpacity(0),
overlayColor.withOpacity(0),
overlayColor.withOpacity(0.1),
],
),
),
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: FladderImage(
fit: BoxFit.cover,
image: currentItem.bannerImage,
),
),
),
),
),
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
currentItem.title,
maxLines: 2,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
shadows: shadows,
color: Colors.white,
),
),
),
if (currentItem.label(context) != null && currentItem is! MovieModel)
Flexible(
child: Text(
currentItem.label(context)!,
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
shadows: shadows,
color: Colors.white.withOpacity(0.75),
),
),
),
if (currentItem.overview.summary.isNotEmpty &&
AdaptiveLayout.layoutOf(context) != LayoutState.phone)
Flexible(
child: Text(
currentItem.overview.summary,
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
shadows: shadows,
color: Colors.white.withOpacity(0.75),
),
),
),
].addInBetween(const SizedBox(height: 6)),
),
),
),
].addInBetween(const SizedBox(height: 16)),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: AnimatedOpacity(
opacity: showControls ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filledTonal(
onPressed: () => nextSlide(),
icon: const Icon(IconsaxOutline.arrow_right_3),
)
],
),
),
),
],
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: PopupMenuButton(
onOpened: () => interacting = true,
onCanceled: () {
interacting = false;
timer.reset();
},
itemBuilder: (context) => actions.popupMenuItems(useIcons: true),
),
),
),
),
],
),
),
),
),
),
GestureDetector(
onHorizontalDragUpdate: (details) {
final delta = (details.primaryDelta ?? 0) / 20;
slidePosition += delta;
if (slidePosition > 1) {
nextSlide();
slidePosition = 0;
} else if (slidePosition < -1) {
previousSlide();
slidePosition = 0;
}
},
onHorizontalDragStart: (details) {
slidePosition = 0;
},
child: Container(
color: Colors.black.withOpacity(0),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center,
children: widget.items.mapIndexed((index, e) {
return Tooltip(
message: '${e.name}\n${e.detailedName}',
child: Card(
elevation: 0,
color: Colors.transparent,
child: InkWell(
onTapUp: currentPage == index
? null
: (details) {
animateToTarget(index);
timer.reset();
},
child: Container(
alignment: Alignment.center,
color: Colors.red.withOpacity(0),
width: 28,
height: 28,
child: AnimatedContainer(
duration: const Duration(milliseconds: 125),
width: currentItem == e ? 22 : 6,
height: currentItem == e ? 10 : 6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: currentItem == e
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary.withOpacity(0.25),
),
),
),
),
),
);
}).toList(),
),
),
),
)
],
);
}
void animateToTarget(int nextIndex) {
int step = currentPage < nextIndex ? 1 : -1;
void updateItem(int item) {
Future.delayed(Duration(milliseconds: 64 ~/ ((currentPage - nextIndex).abs() / 3)), () {
setState(() {
currentPage = item;
});
if (currentPage != nextIndex) {
updateItem(item + step);
}
});
timer.reset();
}
updateItem(currentPage + step);
}
}

View file

@ -0,0 +1,752 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//This is a copy of the CarouselView widget with some minor changes.
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class MyCustomScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();
}
/// A Material Design carousel widget.
///
/// The [FladderCarousel] present a scrollable list of items, each of which can dynamically
/// change size based on the chosen layout.
///
/// This widget supports uncontained carousel layout. It shows items that scroll
/// to the edge of the container, behaving similarly to a [ListView] where all
/// children are a uniform size.
///
/// The [FladderCarouselController] is used to control the [FladderCarouselController.initialItem].
///
/// The [FladderCarousel.itemExtent] property must be non-null and defines the base
/// size of items. While items typically maintain this size, the first and last
/// visible items may be slightly compressed during scrolling. The [shrinkExtent]
/// property controls the minimum allowable size for these compressed items.
///
/// {@tool dartpad}
/// Here is an example of [FladderCarousel] to show the uncontained layout. Each carousel
/// item has the same size but can be "squished" to the [shrinkExtent] when they
/// are show on the view and out of view.
///
/// ** See code in examples/api/lib/material/carousel/carousel.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [FladderCarouselController], which controls the first visible item in the carousel.
/// * [PageView], which is a scrollable list that works page by page.
class FladderCarousel extends StatefulWidget {
/// Creates a Material Design carousel.
const FladderCarousel({
super.key,
this.padding,
this.backgroundColor,
this.elevation,
this.shape,
this.overlayColor,
this.itemSnapping = false,
this.shrinkExtent = 0.0,
this.controller,
this.scrollDirection = Axis.horizontal,
this.reverse = false,
this.onTap,
this.onLongPress,
this.onSecondaryTap,
required this.itemExtent,
required this.children,
});
/// The amount of space to surround each carousel item with.
///
/// Defaults to [EdgeInsets.all] of 4 pixels.
final EdgeInsets? padding;
/// The background color for each carousel item.
///
/// Defaults to [ColorScheme.surface].
final Color? backgroundColor;
/// The z-coordinate of each carousel item.
///
/// Defaults to 0.0.
final double? elevation;
/// The shape of each carousel item's [Material].
///
/// Defines each item's [Material.shape].
///
/// Defaults to a [RoundedRectangleBorder] with a circular corner radius
/// of 28.0.
final ShapeBorder? shape;
/// The highlight color to indicate the carousel items are in pressed, hovered
/// or focused states.
///
/// The default values are:
/// * [WidgetState.pressed] - [ColorScheme.onSurface] with an opacity of 0.1
/// * [WidgetState.hovered] - [ColorScheme.onSurface] with an opacity of 0.08
/// * [WidgetState.focused] - [ColorScheme.onSurface] with an opacity of 0.1
final WidgetStateProperty<Color?>? overlayColor;
/// The minimum allowable extent (size) in the main axis for carousel items
/// during scrolling transitions.
///
/// As the carousel scrolls, the first visible item is pinned and gradually
/// shrinks until it reaches this minimum extent before scrolling off-screen.
/// Similarly, the last visible item enters the viewport at this minimum size
/// and expands to its full [itemExtent].
///
/// In cases where the remaining viewport space for the last visible item is
/// larger than the defined [shrinkExtent], the [shrinkExtent] is dynamically
/// adjusted to match this remaining space, ensuring a smooth size transition.
///
/// Defaults to 0.0. Setting to 0.0 allows items to shrink/expand completely,
/// transitioning between 0.0 and the full [itemExtent]. In cases where the
/// remaining viewport space for the last visible item is larger than the
/// defined [shrinkExtent], the [shrinkExtent] is dynamically adjusted to match
/// this remaining space, ensuring a smooth size transition.
final double shrinkExtent;
/// Whether the carousel should keep scrolling to the next/previous items to
/// maintain the original layout.
///
/// Defaults to false.
final bool itemSnapping;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
final FladderCarouselController? controller;
/// The [Axis] along which the scroll view's offset increases with each item.
///
/// Defaults to [Axis.horizontal].
final Axis scrollDirection;
/// Whether the carousel list scrolls in the reading direction.
///
/// For example, if the reading direction is left-to-right and
/// [scrollDirection] is [Axis.horizontal], then the carousel scrolls from
/// left to right when [reverse] is false and from right to left when
/// [reverse] is true.
///
/// Similarly, if [scrollDirection] is [Axis.vertical], then the carousel view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
final bool reverse;
/// Called when one of the [children] is tapped.
final ValueChanged<int>? onTap;
/// Called when one of the [children] is longPressed.
final ValueChanged<int>? onLongPress;
final ValueChanged<(int, TapDownDetails)>? onSecondaryTap;
/// The extent the children are forced to have in the main axis.
///
/// The item extent should not exceed the available space that the carousel
/// occupies to ensure at least one item is fully visible.
///
/// This must be non-null.
final double itemExtent;
/// The child widgets for the carousel.
final List<Widget> children;
@override
State<FladderCarousel> createState() => _CarouselViewState();
}
class _CarouselViewState extends State<FladderCarousel> {
late double _itemExtent;
FladderCarouselController? _internalController;
FladderCarouselController get _controller => widget.controller ?? _internalController!;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_internalController = FladderCarouselController();
}
_controller._attach(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_itemExtent = widget.itemExtent;
}
@override
void didUpdateWidget(covariant FladderCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller?._detach(this);
if (widget.controller != null) {
_internalController?._detach(this);
_internalController = null;
widget.controller?._attach(this);
} else {
// widget.controller == null && oldWidget.controller != null
assert(_internalController == null);
_internalController = FladderCarouselController();
_controller._attach(this);
}
}
if (widget.itemExtent != oldWidget.itemExtent) {
_itemExtent = widget.itemExtent;
}
}
@override
void dispose() {
_controller._detach(this);
_internalController?.dispose();
super.dispose();
}
AxisDirection _getDirection(BuildContext context) {
switch (widget.scrollDirection) {
case Axis.horizontal:
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection);
return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection;
case Axis.vertical:
return widget.reverse ? AxisDirection.up : AxisDirection.down;
}
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AxisDirection axisDirection = _getDirection(context);
final ScrollPhysics physics =
widget.itemSnapping ? const CarouselScrollPhysics() : ScrollConfiguration.of(context).getScrollPhysics(context);
final EdgeInsets effectivePadding = widget.padding ?? const EdgeInsets.all(4.0);
final Color effectiveBackgroundColor = widget.backgroundColor ?? Theme.of(context).colorScheme.surface;
final double effectiveElevation = widget.elevation ?? 0.0;
final ShapeBorder effectiveShape =
widget.shape ?? const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0)));
return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
final double mainAxisExtent = switch (widget.scrollDirection) {
Axis.horizontal => constraints.maxWidth,
Axis.vertical => constraints.maxHeight,
};
_itemExtent = clampDouble(_itemExtent, 0, mainAxisExtent);
return Scrollable(
axisDirection: axisDirection,
scrollBehavior: MyCustomScrollBehavior(),
controller: _controller,
physics: physics,
viewportBuilder: (BuildContext context, ViewportOffset position) {
return Viewport(
cacheExtent: 0.0,
cacheExtentStyle: CacheExtentStyle.viewport,
axisDirection: axisDirection,
offset: position,
clipBehavior: Clip.antiAlias,
slivers: <Widget>[
_SliverFixedExtentCarousel(
itemExtent: _itemExtent,
minExtent: widget.shrinkExtent,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Padding(
padding: effectivePadding,
child: Material(
clipBehavior: Clip.antiAlias,
color: effectiveBackgroundColor,
elevation: effectiveElevation,
shape: effectiveShape,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
widget.children.elementAt(index),
Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap != null ? () => widget.onTap!.call(index) : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress!.call(index) : null,
onSecondaryTapDown: widget.onSecondaryTap != null
? (details) => widget.onSecondaryTap!.call((index, details))
: null,
overlayColor: widget.overlayColor ??
WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
return theme.colorScheme.onSurface.withOpacity(0.1);
}
if (states.contains(WidgetState.hovered)) {
return theme.colorScheme.onSurface.withOpacity(0.08);
}
if (states.contains(WidgetState.focused)) {
return theme.colorScheme.onSurface.withOpacity(0.1);
}
return null;
}),
),
),
],
),
),
);
},
childCount: widget.children.length,
),
),
],
);
},
);
});
}
}
/// A sliver that displays its box children in a linear array with a fixed extent
/// per item.
///
/// _To learn more about slivers, see [CustomScrollView.slivers]._
///
/// This sliver list arranges its children in a line along the main axis starting
/// at offset zero and without gaps. Each child is constrained to a fixed extent
/// along the main axis and the [SliverConstraints.crossAxisExtent]
/// along the cross axis. The difference between this and a list view with a fixed
/// extent is the first item and last item can be squished a little during scrolling
/// transition. This compression is controlled by the `minExtent` property and
/// aligns with the [Material Design Carousel specifications]
/// (https://m3.material.io/components/carousel/guidelines#96c5c157-fe5b-4ee3-a9b4-72bf8efab7e9).
class _SliverFixedExtentCarousel extends SliverMultiBoxAdaptorWidget {
const _SliverFixedExtentCarousel({
required super.delegate,
required this.minExtent,
required this.itemExtent,
});
final double itemExtent;
final double minExtent;
@override
RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement;
return _RenderSliverFixedExtentCarousel(
childManager: element,
minExtent: minExtent,
maxExtent: itemExtent,
);
}
@override
void updateRenderObject(BuildContext context, _RenderSliverFixedExtentCarousel renderObject) {
renderObject.maxExtent = itemExtent;
renderObject.minExtent = minExtent;
}
}
class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor {
_RenderSliverFixedExtentCarousel({
required super.childManager,
required double maxExtent,
required double minExtent,
}) : _maxExtent = maxExtent,
_minExtent = minExtent;
double get maxExtent => _maxExtent;
double _maxExtent;
set maxExtent(double value) {
if (_maxExtent == value) {
return;
}
_maxExtent = value;
markNeedsLayout();
}
double get minExtent => _minExtent;
double _minExtent;
set minExtent(double value) {
if (_minExtent == value) {
return;
}
_minExtent = value;
markNeedsLayout();
}
// This implements the [itemExtentBuilder] callback.
double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) {
final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor();
// Calculate how many items have been completely scroll off screen.
final int offscreenItems = (constraints.scrollOffset / maxExtent).floor();
// If an item is partially off screen and partially on screen,
// `constraints.scrollOffset` must be greater than
// `offscreenItems * maxExtent`, so the difference between these two is how
// much the current first visible item is off screen.
final double offscreenExtent = constraints.scrollOffset - offscreenItems * maxExtent;
// If there is not enough space to place the last visible item but the remaining
// space is larger than `minExtent`, the extent for last item should be at
// least the remaining extent to ensure a smooth size transition.
final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent);
// Two special cases are the first and last visible items. Other items' extent
// should all return `maxExtent`.
if (index == firstVisibleIndex) {
final double effectiveExtent = maxExtent - offscreenExtent;
return math.max(effectiveExtent, effectiveMinExtent);
}
final double scrollOffsetForLastIndex = constraints.scrollOffset + constraints.remainingPaintExtent;
if (index == getMaxChildIndexForScrollOffset(scrollOffsetForLastIndex, maxExtent)) {
return clampDouble(scrollOffsetForLastIndex - maxExtent * index, effectiveMinExtent, maxExtent);
}
return maxExtent;
}
late SliverLayoutDimensions _currentLayoutDimensions;
@override
void performLayout() {
_currentLayoutDimensions = SliverLayoutDimensions(
scrollOffset: constraints.scrollOffset,
precedingScrollExtent: constraints.precedingScrollExtent,
viewportMainAxisExtent: constraints.viewportMainAxisExtent,
crossAxisExtent: constraints.crossAxisExtent,
);
super.performLayout();
}
/// The layout offset for the child with the given index.
@override
double indexToLayoutOffset(
@Deprecated('The itemExtent is already available within the scope of this function. '
'This feature was deprecated after v3.20.0-7.0.pre.')
double itemExtent,
int index,
) {
final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor();
// If there is not enough space to place the last visible item but the remaining
// space is larger than `minExtent`, the extent for last item should be at
// least the remaining extent to make sure a smooth size transition.
final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent);
if (index == firstVisibleIndex) {
final double firstVisibleItemExtent = _buildItemExtent(index, _currentLayoutDimensions);
// If the first item is squished to be less than `effectievMinExtent`,
// then it should stop changinng its size and should start to scroll off screen.
if (firstVisibleItemExtent <= effectiveMinExtent) {
return maxExtent * index - effectiveMinExtent + maxExtent;
}
return constraints.scrollOffset;
}
return maxExtent * index;
}
/// The minimum child index that is visible at the given scroll offset.
@override
int getMinChildIndexForScrollOffset(
double scrollOffset,
@Deprecated('The itemExtent is already available within the scope of this function. '
'This feature was deprecated after v3.20.0-7.0.pre.')
double itemExtent,
) {
final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor();
return math.max(firstVisibleIndex, 0);
}
/// The maximum child index that is visible at the given scroll offset.
@override
int getMaxChildIndexForScrollOffset(
double scrollOffset,
@Deprecated('The itemExtent is already available within the scope of this function. '
'This feature was deprecated after v3.20.0-7.0.pre.')
double itemExtent,
) {
if (maxExtent > 0.0) {
final double actual = scrollOffset / maxExtent - 1;
final int round = actual.round();
if ((actual * maxExtent - round * maxExtent).abs() < precisionErrorTolerance) {
return math.max(0, round);
}
return math.max(0, actual.ceil());
}
return 0;
}
@override
double? get itemExtent => null;
@override
ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent;
}
/// Scroll physics used by a [FladderCarousel].
///
/// These physics cause the carousel item to snap to item boundaries.
///
/// See also:
///
/// * [ScrollPhysics], the base class which defines the API for scrolling
/// physics.
/// * [PageScrollPhysics], scroll physics used by a [PageView].
class CarouselScrollPhysics extends ScrollPhysics {
/// Creates physics for a [FladderCarousel].
const CarouselScrollPhysics({super.parent});
@override
CarouselScrollPhysics applyTo(ScrollPhysics? ancestor) {
return CarouselScrollPhysics(parent: buildParent(ancestor));
}
double _getTargetPixels(
_CarouselPosition position,
Tolerance tolerance,
double velocity,
) {
double fraction;
fraction = position.itemExtent! / position.viewportDimension;
final double itemWidth = position.viewportDimension * fraction;
final double actual = math.max(0.0, position.pixels) / itemWidth;
final double round = actual.roundToDouble();
double item;
if ((actual - round).abs() < precisionErrorTolerance) {
item = round;
} else {
item = actual;
}
if (velocity < -tolerance.velocity) {
item -= 0.5;
} else if (velocity > tolerance.velocity) {
item += 0.5;
}
return item.roundToDouble() * itemWidth;
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
assert(
position is _CarouselPosition,
'CarouselScrollPhysics can only be used with Scrollables that uses '
'the FladderCarouselController',
);
final _CarouselPosition metrics = position as _CarouselPosition;
if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) ||
(velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) {
return super.createBallisticSimulation(metrics, velocity);
}
final Tolerance tolerance = toleranceFor(metrics);
final double target = _getTargetPixels(metrics, tolerance, velocity);
if (target != metrics.pixels) {
return ScrollSpringSimulation(
spring,
metrics.pixels,
target,
velocity,
tolerance: tolerance,
);
}
return null;
}
@override
bool get allowImplicitScrolling => true;
}
/// Metrics for a [FladderCarousel].
class _CarouselMetrics extends FixedScrollMetrics {
/// Creates an immutable snapshot of values associated with a [FladderCarousel].
_CarouselMetrics({
required super.minScrollExtent,
required super.maxScrollExtent,
required super.pixels,
required super.viewportDimension,
required super.axisDirection,
this.itemExtent,
required super.devicePixelRatio,
});
/// Extent for the carousel item.
///
/// Used to compute the first item from the current [pixels].
final double? itemExtent;
@override
_CarouselMetrics copyWith({
double? minScrollExtent,
double? maxScrollExtent,
double? pixels,
double? viewportDimension,
AxisDirection? axisDirection,
double? itemExtent,
double? devicePixelRatio,
}) {
return _CarouselMetrics(
minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null),
maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null),
pixels: pixels ?? (hasPixels ? this.pixels : null),
viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null),
axisDirection: axisDirection ?? this.axisDirection,
itemExtent: itemExtent ?? this.itemExtent,
devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
);
}
}
class _CarouselPosition extends ScrollPositionWithSingleContext implements _CarouselMetrics {
_CarouselPosition({
required super.physics,
required super.context,
this.initialItem = 0,
required this.itemExtent,
super.oldPosition,
}) : _itemToShowOnStartup = initialItem.toDouble(),
super(initialPixels: null);
final int initialItem;
final double _itemToShowOnStartup;
// When the viewport has a zero-size, the item can not
// be retrieved by `getItemFromPixels`, so we need to cache the item
// for use when resizing the viewport to non-zero next time.
double? _cachedItem;
@override
double? itemExtent;
double getItemFromPixels(double pixels, double viewportDimension) {
assert(viewportDimension > 0.0);
final double fraction = itemExtent! / viewportDimension;
final double actual = math.max(0.0, pixels) / (viewportDimension * fraction);
final double round = actual.roundToDouble();
if ((actual - round).abs() < precisionErrorTolerance) {
return round;
}
return actual;
}
double getPixelsFromItem(double item) {
final double fraction = itemExtent! / viewportDimension;
return item * viewportDimension * fraction;
}
@override
bool applyViewportDimension(double viewportDimension) {
final double? oldViewportDimensions = hasViewportDimension ? this.viewportDimension : null;
if (viewportDimension == oldViewportDimensions) {
return true;
}
final bool result = super.applyViewportDimension(viewportDimension);
final double? oldPixels = hasPixels ? pixels : null;
double item;
if (oldPixels == null) {
item = _itemToShowOnStartup;
} else if (oldViewportDimensions == 0.0) {
// If resize from zero, we should use the _cachedItem to recover the state.
item = _cachedItem!;
} else {
item = getItemFromPixels(oldPixels, oldViewportDimensions!);
}
final double newPixels = getPixelsFromItem(item);
// If the viewportDimension is zero, cache the item
// in case the viewport is resized to be non-zero.
_cachedItem = (viewportDimension == 0.0) ? item : null;
if (newPixels != oldPixels) {
correctPixels(newPixels);
return false;
}
return result;
}
@override
_CarouselMetrics copyWith({
double? minScrollExtent,
double? maxScrollExtent,
double? pixels,
double? viewportDimension,
AxisDirection? axisDirection,
double? itemExtent,
List<int>? layoutWeights,
double? devicePixelRatio,
}) {
return _CarouselMetrics(
minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null),
maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null),
pixels: pixels ?? (hasPixels ? this.pixels : null),
viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null),
axisDirection: axisDirection ?? this.axisDirection,
itemExtent: itemExtent ?? this.itemExtent,
devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
);
}
}
/// A controller for [FladderCarousel].
///
/// Using a carousel controller helps to show the first visible item on the
/// carousel list.
class FladderCarouselController extends ScrollController {
/// Creates a carousel controller.
FladderCarouselController({
this.initialItem = 0,
});
/// The item that expands to the maximum size when first creating the [FladderCarousel].
final int initialItem;
_CarouselViewState? _carouselState;
// ignore: use_setters_to_change_properties
void _attach(_CarouselViewState anchor) {
_carouselState = anchor;
}
void _detach(_CarouselViewState anchor) {
if (_carouselState == anchor) {
_carouselState = null;
}
}
@override
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
assert(_carouselState != null);
final double itemExtent = _carouselState!._itemExtent;
return _CarouselPosition(
physics: physics,
context: context,
initialItem: initialItem,
itemExtent: itemExtent,
oldPosition: oldPosition,
);
}
@override
void attach(ScrollPosition position) {
super.attach(position);
final _CarouselPosition carouselPosition = position as _CarouselPosition;
carouselPosition.itemExtent = _carouselState!._itemExtent;
}
}