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

View file

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

View file

@ -25,6 +25,9 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson(
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
ThemeMode.system,
themeColor: $enumDecodeNullable(_$ColorThemesEnumMap, json['themeColor']),
homeBanner:
$enumDecodeNullable(_$HomeBannerEnumMap, json['homeBanner']) ??
HomeBanner.carousel,
amoledBlack: json['amoledBlack'] as bool? ?? false,
blurPlaceHolders: json['blurPlaceHolders'] as bool? ?? false,
blurUpcomingEpisodes: json['blurUpcomingEpisodes'] as bool? ?? false,
@ -47,6 +50,7 @@ Map<String, dynamic> _$$ClientSettingsModelImplToJson(
'nextUpDateCutoff': instance.nextUpDateCutoff?.inMicroseconds,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'themeColor': _$ColorThemesEnumMap[instance.themeColor],
'homeBanner': _$HomeBannerEnumMap[instance.homeBanner]!,
'amoledBlack': instance.amoledBlack,
'blurPlaceHolders': instance.blurPlaceHolders,
'blurUpcomingEpisodes': instance.blurUpcomingEpisodes,
@ -81,3 +85,8 @@ const _$ColorThemesEnumMap = {
ColorThemes.deepPurple: 'deepPurple',
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/views_provider.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/nested_scaffold.dart';
import 'package:fladder/screens/shared/nested_sliver_appbar.dart';
@ -104,20 +104,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
SliverToBoxAdapter(
child: Transform.translate(
offset: Offset(0, AdaptiveLayout.layoutOf(context) == LayoutState.phone ? -14 : 0),
child: ConstrainedBox(
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,
),
),
),
),
child: TopPostersRow(posters: homeCarouselItems),
),
),
} 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_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/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/settings/home_settings_provider.dart';
@ -186,6 +187,28 @@ class _ClientSettingsPageState extends ConsumerState<ClientSettingsPage> {
.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(
label: Text(context.localized.settingsHomeNextUpTitle),
subLabel: Text(context.localized.settingsHomeNextUpDesc),

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
@ -45,7 +44,7 @@ class SettingsScaffold extends ConsumerWidget {
leading: context.router.backButton(),
flexibleSpace: FlexibleSpaceBar(
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(
children: [
Text(label, style: Theme.of(context).textTheme.headlineLarge),
@ -75,8 +74,7 @@ class SettingsScaffold extends ConsumerWidget {
),
),
SliverPadding(
padding: MediaQuery.paddingOf(context)
.copyWith(top: AdaptiveLayout.of(context).isDesktop || kIsWeb ? 0 : null),
padding: MediaQuery.paddingOf(context).copyWith(top: AdaptiveLayout.of(context).isDesktop ? 0 : 8),
sliver: SliverList(
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: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/screens/shared/media/components/media_play_button.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/item_base_model/play_item_helpers.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';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CarouselBanner extends ConsumerStatefulWidget {
final PageController? controller;
final List<ItemBaseModel> items;
final double maxHeight;
const CarouselBanner({
this.controller,
required this.items,
this.maxHeight = 250,
super.key,
});
@ -29,350 +28,120 @@ class CarouselBanner extends ConsumerStatefulWidget {
}
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
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: [
Flexible(
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;
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,
),
),
),
),
),
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: widget.maxHeight),
child: LayoutBuilder(
builder: (context, constraints) {
final maxExtent = (constraints.maxHeight * 2.1).clamp(250.0, MediaQuery.sizeOf(context).shortestSide * 0.75);
final border = BorderRadius.circular(18);
return FladderCarousel(
shape: RoundedRectangleBorder(borderRadius: border),
onTap: (index) => widget.items[index].navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer
? null
: (index) {
final poster = widget.items[index];
showBottomSheetPill(
context: context,
item: poster,
content: (scrollContext, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: poster.generateActions(context, ref).listTileItems(scrollContext, useIcons: true),
),
),
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: 3,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
shadows: shadows,
color: Colors.white,
),
),
),
if (currentItem.label(context) != null && currentItem is! MovieModel)
Flexible(
child: Text(
currentItem.label(context)!,
maxLines: 3,
style: Theme.of(context).textTheme.titleLarge?.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: 3,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
shadows: shadows,
color: Colors.white.withOpacity(0.75),
),
),
),
].addInBetween(const SizedBox(height: 6)),
),
);
},
onSecondaryTap: AdaptiveLayout.of(context).inputDevice == InputDevice.touch
? null
: (details) async {
Offset localPosition = details.$2.globalPosition;
RelativeRect position = RelativeRect.fromLTRB(
localPosition.dx - 320, localPosition.dy, localPosition.dx, localPosition.dy);
final poster = widget.items[details.$1];
await showMenu(
context: context,
position: position,
items: poster.generateActions(context, ref).popupMenuItems(useIcons: true),
);
},
itemExtent: maxExtent,
children: [
...widget.items.mapIndexed(
(index, e) => LayoutBuilder(builder: (context, constraints) {
final opacity = (constraints.maxWidth / maxExtent);
return Stack(
clipBehavior: Clip.none,
children: [
FladderImage(image: e.bannerImage),
AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: opacity.clamp(0, 1),
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topCenter,
colors: [
Theme.of(context).colorScheme.primaryContainer.withOpacity(0.75),
Colors.transparent,
],
),
),
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(
alignment: Alignment.bottomRight,
alignment: Alignment.bottomLeft,
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),
),
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.title,
maxLines: 2,
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)),
),
),
),
],
),
),
),
),
),
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),
Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.0,
),
),
),
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;
}
}