mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
cover art color scheme extraction (in background)
refactor text styles to use theme port over part of album screen
This commit is contained in:
parent
b9a094c1c4
commit
6609671ae2
@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../images/images.dart';
|
|
||||||
import '../../sources/models.dart';
|
import '../../sources/models.dart';
|
||||||
|
import '../ui/images.dart';
|
||||||
import '../util/clip.dart';
|
import '../util/clip.dart';
|
||||||
|
|
||||||
class AlbumGridTile extends HookConsumerWidget {
|
class AlbumGridTile extends HookConsumerWidget {
|
||||||
@ -25,7 +25,10 @@ class AlbumGridTile extends HookConsumerWidget {
|
|||||||
margin: EdgeInsets.all(2),
|
margin: EdgeInsets.all(2),
|
||||||
child: ImageCard(
|
child: ImageCard(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: CoverArtImage(coverArt: album.coverArt),
|
child: CoverArtImage(
|
||||||
|
coverArt: album.coverArt,
|
||||||
|
thumbnail: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -47,7 +50,10 @@ class ArtistListTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleClip(
|
leading: CircleClip(
|
||||||
child: CoverArtImage(coverArt: artist.coverArt),
|
child: CoverArtImage(
|
||||||
|
coverArt: artist.coverArt,
|
||||||
|
thumbnail: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(artist.name),
|
title: Text(artist.name),
|
||||||
subtitle: albumCount != null ? Text('$albumCount albums') : null,
|
subtitle: albumCount != null ? Text('$albumCount albums') : null,
|
||||||
|
|||||||
@ -1,8 +1,16 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class AlbumScreen extends StatelessWidget {
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../l10n/generated/app_localizations.dart';
|
||||||
|
import '../state/database.dart';
|
||||||
|
import '../state/source.dart';
|
||||||
|
import '../ui/cover_art_theme.dart';
|
||||||
|
import '../ui/images.dart';
|
||||||
|
|
||||||
|
class AlbumScreen extends HookConsumerWidget {
|
||||||
const AlbumScreen({
|
const AlbumScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -11,27 +19,126 @@ class AlbumScreen extends StatelessWidget {
|
|||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
final l = AppLocalizations.of(context);
|
||||||
body: Center(
|
|
||||||
child: Column(
|
final db = ref.watch(databaseProvider);
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
final sourceId = ref.watch(sourceIdProvider);
|
||||||
children: [
|
|
||||||
Text('Album $id!'),
|
final getAlbum = useMemoized(
|
||||||
TextButton(
|
() => db.libraryDao.getAlbum(sourceId, id).getSingle(),
|
||||||
onPressed: () {
|
);
|
||||||
context.push('/artist');
|
final album = useFuture(getAlbum).data;
|
||||||
},
|
|
||||||
child: Text('Artist...'),
|
if (album == null) {
|
||||||
),
|
return Container();
|
||||||
CachedNetworkImage(
|
}
|
||||||
imageUrl: 'https://placehold.net/400x400.png',
|
|
||||||
placeholder: (context, url) => CircularProgressIndicator(),
|
return CoverArtTheme(
|
||||||
errorWidget: (context, url, error) => Icon(Icons.error),
|
coverArt: album.coverArt,
|
||||||
),
|
child: Scaffold(
|
||||||
],
|
body: Center(
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsetsGeometry.symmetric(horizontal: 16),
|
||||||
|
child: _Header(
|
||||||
|
title: album.name,
|
||||||
|
subtitle: album.albumArtist,
|
||||||
|
coverArt: album.coverArt,
|
||||||
|
playText: l.resourcesAlbumActionsPlay,
|
||||||
|
onPlay: () {},
|
||||||
|
onMore: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _Header extends HookConsumerWidget {
|
||||||
|
const _Header({
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.coverArt,
|
||||||
|
this.playText,
|
||||||
|
this.onPlay,
|
||||||
|
this.onMore,
|
||||||
|
// required this.downloadActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? coverArt;
|
||||||
|
final String? playText;
|
||||||
|
final void Function()? onPlay;
|
||||||
|
final FutureOr<void> Function()? onMore;
|
||||||
|
// final List<DownloadAction> downloadActions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// final inheritedStyle = DefaultTextStyle.of(context).style;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CoverArtImage(
|
||||||
|
height: 300,
|
||||||
|
thumbnail: false,
|
||||||
|
coverArt: coverArt,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle ?? '',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.download_done_rounded),
|
||||||
|
),
|
||||||
|
if (onPlay != null)
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onPlay,
|
||||||
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
|
label: Text(
|
||||||
|
playText ?? '',
|
||||||
|
// style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
// color: theme.colorScheme.onPrimary,
|
||||||
|
// ),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onMore != null)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onMore,
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import '../../l10n/generated/app_localizations.dart';
|
|||||||
import '../lists/albums_grid.dart';
|
import '../lists/albums_grid.dart';
|
||||||
import '../lists/artists_list.dart';
|
import '../lists/artists_list.dart';
|
||||||
import '../state/services.dart';
|
import '../state/services.dart';
|
||||||
import '../ui/text.dart';
|
|
||||||
import '../util/custom_scroll_fix.dart';
|
import '../util/custom_scroll_fix.dart';
|
||||||
|
|
||||||
const kIconSize = 26.0;
|
const kIconSize = 26.0;
|
||||||
@ -162,6 +161,7 @@ class TabTitleText extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
|
final text = TextTheme.of(context);
|
||||||
|
|
||||||
String tabLocalization(LibraryTab tab) => switch (tab) {
|
String tabLocalization(LibraryTab tab) => switch (tab) {
|
||||||
LibraryTab.albums => l.navigationTabsAlbums,
|
LibraryTab.albums => l.navigationTabsAlbums,
|
||||||
@ -180,7 +180,7 @@ class TabTitleText extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}, [tabName]);
|
}, [tabName]);
|
||||||
|
|
||||||
return TextH1(tabText.value);
|
return Text(tabText.value, style: text.headlineLarge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import '../../l10n/generated/app_localizations.dart';
|
import '../../l10n/generated/app_localizations.dart';
|
||||||
import '../state/database.dart';
|
import '../state/database.dart';
|
||||||
import '../state/source.dart';
|
import '../state/source.dart';
|
||||||
import '../ui/text.dart';
|
|
||||||
|
|
||||||
const kHorizontalPadding = 18.0;
|
const kHorizontalPadding = 18.0;
|
||||||
|
|
||||||
@ -15,10 +14,11 @@ class SettingsScreen extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
|
final text = TextTheme.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextH1(l.navigationTabsSettings),
|
title: Text(l.navigationTabsSettings, style: text.headlineLarge),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
@ -59,6 +59,8 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final text = TextTheme.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -66,7 +68,7 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
|
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
|
||||||
child: TextH2(title),
|
child: Text(title, style: text.headlineMedium),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
56
lib/app/ui/cover_art_theme.dart
Normal file
56
lib/app/ui/cover_art_theme.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../state/source.dart';
|
||||||
|
import '../util/color_scheme.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
class CoverArtTheme extends HookConsumerWidget {
|
||||||
|
const CoverArtTheme({
|
||||||
|
super.key,
|
||||||
|
required this.coverArt,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? coverArt;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final source = ref.watch(sourceProvider);
|
||||||
|
final sourceId = ref.watch(sourceIdProvider);
|
||||||
|
|
||||||
|
final getColorScheme = useMemoized(
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
return await colorSchemefromImageProvider(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
provider: CachedNetworkImageProvider(
|
||||||
|
coverArt != null
|
||||||
|
? source.coverArtUri(coverArt!, thumbnail: true).toString()
|
||||||
|
: 'https://placehold.net/400x400.png',
|
||||||
|
cacheKey: coverArt != null
|
||||||
|
? '$sourceId$coverArt${true}'
|
||||||
|
: 'https://placehold.net/400x400.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
print(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[source, sourceId, coverArt],
|
||||||
|
);
|
||||||
|
|
||||||
|
final colorScheme = useFuture(getColorScheme).data;
|
||||||
|
|
||||||
|
return colorScheme != null
|
||||||
|
? Theme(
|
||||||
|
data: subtracksTheme(colorScheme),
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
: child;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,17 +3,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../app/state/source.dart';
|
import '../state/source.dart';
|
||||||
|
|
||||||
class CoverArtImage extends HookConsumerWidget {
|
class CoverArtImage extends HookConsumerWidget {
|
||||||
const CoverArtImage({
|
const CoverArtImage({
|
||||||
super.key,
|
super.key,
|
||||||
this.coverArt,
|
this.coverArt,
|
||||||
this.thumbnail = false,
|
this.thumbnail = true,
|
||||||
|
this.fit,
|
||||||
|
this.height,
|
||||||
|
this.width,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? coverArt;
|
final String? coverArt;
|
||||||
final bool thumbnail;
|
final bool thumbnail;
|
||||||
|
final BoxFit? fit;
|
||||||
|
final double? height;
|
||||||
|
final double? width;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -24,31 +30,11 @@ class CoverArtImage extends HookConsumerWidget {
|
|||||||
? source.coverArtUri(coverArt!, thumbnail: thumbnail).toString()
|
? source.coverArtUri(coverArt!, thumbnail: thumbnail).toString()
|
||||||
: 'https://placehold.net/400x400.png';
|
: 'https://placehold.net/400x400.png';
|
||||||
|
|
||||||
return BaseImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
// can't use the URL because of token auth, which is a cache-buster
|
|
||||||
cacheKey: '$sourceId$coverArt$thumbnail',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseImage extends HookConsumerWidget {
|
|
||||||
const BaseImage({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.cacheKey,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String imageUrl;
|
|
||||||
final String? cacheKey;
|
|
||||||
final BoxFit fit;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
|
height: height,
|
||||||
|
width: width,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
cacheKey: cacheKey,
|
cacheKey: '$sourceId$coverArt$thumbnail',
|
||||||
placeholder: (context, url) => Icon(Symbols.cached_rounded),
|
placeholder: (context, url) => Icon(Symbols.cached_rounded),
|
||||||
errorWidget: (context, url, error) => Icon(Icons.error),
|
errorWidget: (context, url, error) => Icon(Icons.error),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class TextH1 extends StatelessWidget {
|
|
||||||
const TextH1(
|
|
||||||
this.data, {
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String data;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Text(
|
|
||||||
data,
|
|
||||||
style: theme.textTheme.headlineLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TextH2 extends StatelessWidget {
|
|
||||||
const TextH2(
|
|
||||||
this.data, {
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String data;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Text(
|
|
||||||
data,
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
lib/app/ui/theme.dart
Normal file
28
lib/app/ui/theme.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
ThemeData subtracksTheme([ColorScheme? colorScheme]) {
|
||||||
|
final theme = ThemeData.from(
|
||||||
|
colorScheme:
|
||||||
|
colorScheme ??
|
||||||
|
ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.purple.shade800,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
useMaterial3: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = theme.textTheme;
|
||||||
|
return theme.copyWith(
|
||||||
|
textTheme: text.copyWith(
|
||||||
|
headlineLarge: text.headlineLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
headlineMedium: text.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
headlineSmall: text.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
431
lib/app/util/color_scheme.dart
Normal file
431
lib/app/util/color_scheme.dart
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
/// This file is a fork of the built-in [ColorScheme.fromImageProvider] function
|
||||||
|
/// with the following changes:
|
||||||
|
///
|
||||||
|
/// 1. [_extractColorsFromImageProvider] now runs the Quantizer to extract colors
|
||||||
|
/// in an isolate to prevent jank on the UI thread (especially noticable during
|
||||||
|
/// transitions).
|
||||||
|
///
|
||||||
|
/// 2. [_imageProviderToScaled] has its hard-coded image resize max dimensions
|
||||||
|
/// to 32x32 pixels.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_color_utilities/material_color_utilities.dart';
|
||||||
|
|
||||||
|
/// Generate a [ColorScheme] derived from the given `imageProvider`.
|
||||||
|
///
|
||||||
|
/// Material Color Utilities extracts the dominant color from the
|
||||||
|
/// supplied [ImageProvider]. Using this color, a [ColorScheme] is generated
|
||||||
|
/// with harmonious colors that meet contrast requirements for accessibility.
|
||||||
|
///
|
||||||
|
/// If any of the optional color parameters are non-null, they will be
|
||||||
|
/// used in place of the generated colors for that field in the resulting
|
||||||
|
/// [ColorScheme]. This allows apps to override specific colors for their
|
||||||
|
/// needs.
|
||||||
|
///
|
||||||
|
/// Given the nature of the algorithm, the most dominant color of the
|
||||||
|
/// `imageProvider` may not wind up as one of the [ColorScheme] colors.
|
||||||
|
///
|
||||||
|
/// The provided image will be scaled down to a maximum size of 112x112 pixels
|
||||||
|
/// during color extraction.
|
||||||
|
///
|
||||||
|
/// {@tool dartpad}
|
||||||
|
/// This sample shows how to use [ColorScheme.fromImageProvider] to create
|
||||||
|
/// content-based dynamic color schemes.
|
||||||
|
///
|
||||||
|
/// ** See code in examples/api/lib/material/color_scheme/dynamic_content_color.0.dart **
|
||||||
|
/// {@end-tool}
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [M3 Guidelines: Dynamic color from content](https://m3.material.io/styles/color/dynamic-color/user-generated-color#8af550b9-a19e-4e9f-bb0a-7f611fed5d0f)
|
||||||
|
/// * <https://pub.dev/packages/dynamic_color>, a package to create
|
||||||
|
/// [ColorScheme]s based on a platform's implementation of dynamic color.
|
||||||
|
/// * <https://m3.material.io/styles/color/the-color-system/color-roles>, the
|
||||||
|
/// Material 3 Color system specification.
|
||||||
|
/// * <https://pub.dev/packages/material_color_utilities>, the package
|
||||||
|
/// used to algorithmically determine the dominant color and to generate
|
||||||
|
/// the [ColorScheme].
|
||||||
|
Future<ColorScheme> colorSchemefromImageProvider({
|
||||||
|
required ImageProvider provider,
|
||||||
|
Brightness brightness = Brightness.light,
|
||||||
|
DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot,
|
||||||
|
double contrastLevel = 0.0,
|
||||||
|
Color? primary,
|
||||||
|
Color? onPrimary,
|
||||||
|
Color? primaryContainer,
|
||||||
|
Color? onPrimaryContainer,
|
||||||
|
Color? primaryFixed,
|
||||||
|
Color? primaryFixedDim,
|
||||||
|
Color? onPrimaryFixed,
|
||||||
|
Color? onPrimaryFixedVariant,
|
||||||
|
Color? secondary,
|
||||||
|
Color? onSecondary,
|
||||||
|
Color? secondaryContainer,
|
||||||
|
Color? onSecondaryContainer,
|
||||||
|
Color? secondaryFixed,
|
||||||
|
Color? secondaryFixedDim,
|
||||||
|
Color? onSecondaryFixed,
|
||||||
|
Color? onSecondaryFixedVariant,
|
||||||
|
Color? tertiary,
|
||||||
|
Color? onTertiary,
|
||||||
|
Color? tertiaryContainer,
|
||||||
|
Color? onTertiaryContainer,
|
||||||
|
Color? tertiaryFixed,
|
||||||
|
Color? tertiaryFixedDim,
|
||||||
|
Color? onTertiaryFixed,
|
||||||
|
Color? onTertiaryFixedVariant,
|
||||||
|
Color? error,
|
||||||
|
Color? onError,
|
||||||
|
Color? errorContainer,
|
||||||
|
Color? onErrorContainer,
|
||||||
|
Color? outline,
|
||||||
|
Color? outlineVariant,
|
||||||
|
Color? surface,
|
||||||
|
Color? onSurface,
|
||||||
|
Color? surfaceDim,
|
||||||
|
Color? surfaceBright,
|
||||||
|
Color? surfaceContainerLowest,
|
||||||
|
Color? surfaceContainerLow,
|
||||||
|
Color? surfaceContainer,
|
||||||
|
Color? surfaceContainerHigh,
|
||||||
|
Color? surfaceContainerHighest,
|
||||||
|
Color? onSurfaceVariant,
|
||||||
|
Color? inverseSurface,
|
||||||
|
Color? onInverseSurface,
|
||||||
|
Color? inversePrimary,
|
||||||
|
Color? shadow,
|
||||||
|
Color? scrim,
|
||||||
|
Color? surfaceTint,
|
||||||
|
@Deprecated(
|
||||||
|
'Use surface instead. '
|
||||||
|
'This feature was deprecated after v3.18.0-0.1.pre.',
|
||||||
|
)
|
||||||
|
Color? background,
|
||||||
|
@Deprecated(
|
||||||
|
'Use onSurface instead. '
|
||||||
|
'This feature was deprecated after v3.18.0-0.1.pre.',
|
||||||
|
)
|
||||||
|
Color? onBackground,
|
||||||
|
@Deprecated(
|
||||||
|
'Use surfaceContainerHighest instead. '
|
||||||
|
'This feature was deprecated after v3.18.0-0.1.pre.',
|
||||||
|
)
|
||||||
|
Color? surfaceVariant,
|
||||||
|
}) async {
|
||||||
|
// Extract dominant colors from image.
|
||||||
|
final QuantizerResult quantizerResult = await _extractColorsFromImageProvider(
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
final Map<int, int> colorToCount = quantizerResult.colorToCount.map(
|
||||||
|
(int key, int value) => MapEntry<int, int>(_getArgbFromAbgr(key), value),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Score colors for color scheme suitability.
|
||||||
|
final List<int> scoredResults = Score.score(colorToCount, desired: 1);
|
||||||
|
final ui.Color baseColor = Color(scoredResults.first);
|
||||||
|
|
||||||
|
final DynamicScheme scheme = _buildDynamicScheme(
|
||||||
|
brightness,
|
||||||
|
baseColor,
|
||||||
|
dynamicSchemeVariant,
|
||||||
|
contrastLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ColorScheme(
|
||||||
|
primary: primary ?? Color(MaterialDynamicColors.primary.getArgb(scheme)),
|
||||||
|
onPrimary:
|
||||||
|
onPrimary ?? Color(MaterialDynamicColors.onPrimary.getArgb(scheme)),
|
||||||
|
primaryContainer:
|
||||||
|
primaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.primaryContainer.getArgb(scheme)),
|
||||||
|
onPrimaryContainer:
|
||||||
|
onPrimaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.onPrimaryContainer.getArgb(scheme)),
|
||||||
|
primaryFixed:
|
||||||
|
primaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.primaryFixed.getArgb(scheme)),
|
||||||
|
primaryFixedDim:
|
||||||
|
primaryFixedDim ??
|
||||||
|
Color(MaterialDynamicColors.primaryFixedDim.getArgb(scheme)),
|
||||||
|
onPrimaryFixed:
|
||||||
|
onPrimaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.onPrimaryFixed.getArgb(scheme)),
|
||||||
|
onPrimaryFixedVariant:
|
||||||
|
onPrimaryFixedVariant ??
|
||||||
|
Color(MaterialDynamicColors.onPrimaryFixedVariant.getArgb(scheme)),
|
||||||
|
secondary:
|
||||||
|
secondary ?? Color(MaterialDynamicColors.secondary.getArgb(scheme)),
|
||||||
|
onSecondary:
|
||||||
|
onSecondary ?? Color(MaterialDynamicColors.onSecondary.getArgb(scheme)),
|
||||||
|
secondaryContainer:
|
||||||
|
secondaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.secondaryContainer.getArgb(scheme)),
|
||||||
|
onSecondaryContainer:
|
||||||
|
onSecondaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.onSecondaryContainer.getArgb(scheme)),
|
||||||
|
secondaryFixed:
|
||||||
|
secondaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.secondaryFixed.getArgb(scheme)),
|
||||||
|
secondaryFixedDim:
|
||||||
|
secondaryFixedDim ??
|
||||||
|
Color(MaterialDynamicColors.secondaryFixedDim.getArgb(scheme)),
|
||||||
|
onSecondaryFixed:
|
||||||
|
onSecondaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.onSecondaryFixed.getArgb(scheme)),
|
||||||
|
onSecondaryFixedVariant:
|
||||||
|
onSecondaryFixedVariant ??
|
||||||
|
Color(MaterialDynamicColors.onSecondaryFixedVariant.getArgb(scheme)),
|
||||||
|
tertiary: tertiary ?? Color(MaterialDynamicColors.tertiary.getArgb(scheme)),
|
||||||
|
onTertiary:
|
||||||
|
onTertiary ?? Color(MaterialDynamicColors.onTertiary.getArgb(scheme)),
|
||||||
|
tertiaryContainer:
|
||||||
|
tertiaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.tertiaryContainer.getArgb(scheme)),
|
||||||
|
onTertiaryContainer:
|
||||||
|
onTertiaryContainer ??
|
||||||
|
Color(MaterialDynamicColors.onTertiaryContainer.getArgb(scheme)),
|
||||||
|
tertiaryFixed:
|
||||||
|
tertiaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.tertiaryFixed.getArgb(scheme)),
|
||||||
|
tertiaryFixedDim:
|
||||||
|
tertiaryFixedDim ??
|
||||||
|
Color(MaterialDynamicColors.tertiaryFixedDim.getArgb(scheme)),
|
||||||
|
onTertiaryFixed:
|
||||||
|
onTertiaryFixed ??
|
||||||
|
Color(MaterialDynamicColors.onTertiaryFixed.getArgb(scheme)),
|
||||||
|
onTertiaryFixedVariant:
|
||||||
|
onTertiaryFixedVariant ??
|
||||||
|
Color(MaterialDynamicColors.onTertiaryFixedVariant.getArgb(scheme)),
|
||||||
|
error: error ?? Color(MaterialDynamicColors.error.getArgb(scheme)),
|
||||||
|
onError: onError ?? Color(MaterialDynamicColors.onError.getArgb(scheme)),
|
||||||
|
errorContainer:
|
||||||
|
errorContainer ??
|
||||||
|
Color(MaterialDynamicColors.errorContainer.getArgb(scheme)),
|
||||||
|
onErrorContainer:
|
||||||
|
onErrorContainer ??
|
||||||
|
Color(MaterialDynamicColors.onErrorContainer.getArgb(scheme)),
|
||||||
|
outline: outline ?? Color(MaterialDynamicColors.outline.getArgb(scheme)),
|
||||||
|
outlineVariant:
|
||||||
|
outlineVariant ??
|
||||||
|
Color(MaterialDynamicColors.outlineVariant.getArgb(scheme)),
|
||||||
|
surface: surface ?? Color(MaterialDynamicColors.surface.getArgb(scheme)),
|
||||||
|
surfaceDim:
|
||||||
|
surfaceDim ?? Color(MaterialDynamicColors.surfaceDim.getArgb(scheme)),
|
||||||
|
surfaceBright:
|
||||||
|
surfaceBright ??
|
||||||
|
Color(MaterialDynamicColors.surfaceBright.getArgb(scheme)),
|
||||||
|
surfaceContainerLowest:
|
||||||
|
surfaceContainerLowest ??
|
||||||
|
Color(MaterialDynamicColors.surfaceContainerLowest.getArgb(scheme)),
|
||||||
|
surfaceContainerLow:
|
||||||
|
surfaceContainerLow ??
|
||||||
|
Color(MaterialDynamicColors.surfaceContainerLow.getArgb(scheme)),
|
||||||
|
surfaceContainer:
|
||||||
|
surfaceContainer ??
|
||||||
|
Color(MaterialDynamicColors.surfaceContainer.getArgb(scheme)),
|
||||||
|
surfaceContainerHigh:
|
||||||
|
surfaceContainerHigh ??
|
||||||
|
Color(MaterialDynamicColors.surfaceContainerHigh.getArgb(scheme)),
|
||||||
|
surfaceContainerHighest:
|
||||||
|
surfaceContainerHighest ??
|
||||||
|
Color(MaterialDynamicColors.surfaceContainerHighest.getArgb(scheme)),
|
||||||
|
onSurface:
|
||||||
|
onSurface ?? Color(MaterialDynamicColors.onSurface.getArgb(scheme)),
|
||||||
|
onSurfaceVariant:
|
||||||
|
onSurfaceVariant ??
|
||||||
|
Color(MaterialDynamicColors.onSurfaceVariant.getArgb(scheme)),
|
||||||
|
inverseSurface:
|
||||||
|
inverseSurface ??
|
||||||
|
Color(MaterialDynamicColors.inverseSurface.getArgb(scheme)),
|
||||||
|
onInverseSurface:
|
||||||
|
onInverseSurface ??
|
||||||
|
Color(MaterialDynamicColors.inverseOnSurface.getArgb(scheme)),
|
||||||
|
inversePrimary:
|
||||||
|
inversePrimary ??
|
||||||
|
Color(MaterialDynamicColors.inversePrimary.getArgb(scheme)),
|
||||||
|
shadow: shadow ?? Color(MaterialDynamicColors.shadow.getArgb(scheme)),
|
||||||
|
scrim: scrim ?? Color(MaterialDynamicColors.scrim.getArgb(scheme)),
|
||||||
|
surfaceTint:
|
||||||
|
surfaceTint ?? Color(MaterialDynamicColors.primary.getArgb(scheme)),
|
||||||
|
brightness: brightness,
|
||||||
|
// DEPRECATED (newest deprecations at the bottom)
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
background:
|
||||||
|
background ?? Color(MaterialDynamicColors.background.getArgb(scheme)),
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
onBackground:
|
||||||
|
onBackground ??
|
||||||
|
Color(MaterialDynamicColors.onBackground.getArgb(scheme)),
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
surfaceVariant:
|
||||||
|
surfaceVariant ??
|
||||||
|
Color(MaterialDynamicColors.surfaceVariant.getArgb(scheme)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorScheme.fromImageProvider() utilities.
|
||||||
|
|
||||||
|
/// Extracts bytes from an [ImageProvider] and returns a [QuantizerResult]
|
||||||
|
/// containing the most dominant colors.
|
||||||
|
Future<QuantizerResult> _extractColorsFromImageProvider(
|
||||||
|
ImageProvider imageProvider,
|
||||||
|
) async {
|
||||||
|
final ui.Image scaledImage = await _imageProviderToScaled(imageProvider);
|
||||||
|
final ByteData? imageBytes = await scaledImage.toByteData();
|
||||||
|
|
||||||
|
return Isolate.run(
|
||||||
|
() => QuantizerCelebi().quantize(
|
||||||
|
imageBytes!.buffer.asUint32List(),
|
||||||
|
128,
|
||||||
|
returnInputPixelToClusterPixel: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale image size down to reduce computation time of color extraction.
|
||||||
|
Future<ui.Image> _imageProviderToScaled(ImageProvider imageProvider) async {
|
||||||
|
const double maxDimension = 32.0;
|
||||||
|
final ImageStream stream = imageProvider.resolve(
|
||||||
|
const ImageConfiguration(size: Size(maxDimension, maxDimension)),
|
||||||
|
);
|
||||||
|
final Completer<ui.Image> imageCompleter = Completer<ui.Image>();
|
||||||
|
late ImageStreamListener listener;
|
||||||
|
late ui.Image scaledImage;
|
||||||
|
Timer? loadFailureTimeout;
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(ImageInfo info, bool sync) async {
|
||||||
|
loadFailureTimeout?.cancel();
|
||||||
|
stream.removeListener(listener);
|
||||||
|
final ui.Image image = info.image;
|
||||||
|
final int width = image.width;
|
||||||
|
final int height = image.height;
|
||||||
|
double paintWidth = width.toDouble();
|
||||||
|
double paintHeight = height.toDouble();
|
||||||
|
assert(width > 0 && height > 0);
|
||||||
|
|
||||||
|
final bool rescale = width > maxDimension || height > maxDimension;
|
||||||
|
if (rescale) {
|
||||||
|
paintWidth = (width > height)
|
||||||
|
? maxDimension
|
||||||
|
: (maxDimension / height) * width;
|
||||||
|
paintHeight = (height > width)
|
||||||
|
? maxDimension
|
||||||
|
: (maxDimension / width) * height;
|
||||||
|
}
|
||||||
|
final ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
|
||||||
|
final Canvas canvas = Canvas(pictureRecorder);
|
||||||
|
paintImage(
|
||||||
|
canvas: canvas,
|
||||||
|
rect: Rect.fromLTRB(0, 0, paintWidth, paintHeight),
|
||||||
|
image: image,
|
||||||
|
filterQuality: FilterQuality.none,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ui.Picture picture = pictureRecorder.endRecording();
|
||||||
|
scaledImage = await picture.toImage(
|
||||||
|
paintWidth.toInt(),
|
||||||
|
paintHeight.toInt(),
|
||||||
|
);
|
||||||
|
imageCompleter.complete(info.image);
|
||||||
|
},
|
||||||
|
onError: (Object exception, StackTrace? stackTrace) {
|
||||||
|
loadFailureTimeout?.cancel();
|
||||||
|
stream.removeListener(listener);
|
||||||
|
imageCompleter.completeError(
|
||||||
|
Exception('Failed to render image: $exception'),
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
loadFailureTimeout = Timer(const Duration(seconds: 5), () {
|
||||||
|
stream.removeListener(listener);
|
||||||
|
imageCompleter.completeError(
|
||||||
|
TimeoutException('Timeout occurred trying to load image'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.addListener(listener);
|
||||||
|
await imageCompleter.future;
|
||||||
|
return scaledImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts AABBGGRR color int to AARRGGBB format.
|
||||||
|
int _getArgbFromAbgr(int abgr) {
|
||||||
|
const int exceptRMask = 0xFF00FFFF;
|
||||||
|
const int onlyRMask = ~exceptRMask;
|
||||||
|
const int exceptBMask = 0xFFFFFF00;
|
||||||
|
const int onlyBMask = ~exceptBMask;
|
||||||
|
final int r = (abgr & onlyRMask) >> 16;
|
||||||
|
final int b = abgr & onlyBMask;
|
||||||
|
return (abgr & exceptRMask & exceptBMask) | (b << 16) | r;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicScheme _buildDynamicScheme(
|
||||||
|
Brightness brightness,
|
||||||
|
Color seedColor,
|
||||||
|
DynamicSchemeVariant schemeVariant,
|
||||||
|
double contrastLevel,
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
contrastLevel >= -1.0 && contrastLevel <= 1.0,
|
||||||
|
'contrastLevel must be between -1.0 and 1.0 inclusive.',
|
||||||
|
);
|
||||||
|
final bool isDark = brightness == Brightness.dark;
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
final Hct sourceColor = Hct.fromInt(seedColor.value);
|
||||||
|
return switch (schemeVariant) {
|
||||||
|
DynamicSchemeVariant.tonalSpot => SchemeTonalSpot(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.fidelity => SchemeFidelity(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.content => SchemeContent(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.monochrome => SchemeMonochrome(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.neutral => SchemeNeutral(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.vibrant => SchemeVibrant(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.expressive => SchemeExpressive(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.rainbow => SchemeRainbow(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
DynamicSchemeVariant.fruitSalad => SchemeFruitSalad(
|
||||||
|
sourceColorHct: sourceColor,
|
||||||
|
isDark: isDark,
|
||||||
|
contrastLevel: contrastLevel,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -58,4 +58,10 @@ class LibraryDao extends DatabaseAccessor<SubtracksDatabase>
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Selectable<models.Album> getAlbum(int sourceId, String id) {
|
||||||
|
return db.managers.albums.filter(
|
||||||
|
(f) => f.sourceId.equals(sourceId) & f.id.equals(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'app/router.dart';
|
import 'app/router.dart';
|
||||||
|
import 'app/ui/theme.dart';
|
||||||
import 'l10n/generated/app_localizations.dart';
|
import 'l10n/generated/app_localizations.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@ -17,7 +18,7 @@ class MainApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
themeMode: ThemeMode.dark,
|
themeMode: ThemeMode.dark,
|
||||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
darkTheme: subtracksTheme(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|||||||
@ -582,7 +582,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
|
|||||||
@ -26,6 +26,7 @@ dependencies:
|
|||||||
infinite_scroll_pagination: ^5.1.1
|
infinite_scroll_pagination: ^5.1.1
|
||||||
intl: any
|
intl: any
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
material_color_utilities: ^0.11.1
|
||||||
material_symbols_icons: ^4.2874.0
|
material_symbols_icons: ^4.2874.0
|
||||||
octo_image: ^2.1.0
|
octo_image: ^2.1.0
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user