mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
feat(library): add Mornye browsing and contextual compact navigation
Add category browsing, a two-column Recently Added grid, and a dedicated download queue. Keep compact navigation icons and their animations aligned with the active tab. Keep profile avatars circular and report lyric enrichment outcomes accurately, preserving existing lyrics and supporting instrumental markers.
This commit is contained in:
@@ -98,10 +98,11 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
);
|
||||
final homeSurface = sideSurface();
|
||||
final leadingSurface = sideSurface();
|
||||
final searchSurface = sideSurface(radius: 32);
|
||||
final mainDestinations = destinations.sublist(0, destinations.length - 1);
|
||||
final searchSelected = selectedIndex == destinations.length - 1;
|
||||
final leadingIndex = searchSelected ? 0 : selectedIndex;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final inactiveIconColor = Color.lerp(
|
||||
scheme.onSurfaceVariant,
|
||||
@@ -109,7 +110,7 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
|
||||
);
|
||||
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
|
||||
Widget tabs({required bool hideHomeIcon}) => TickerMode(
|
||||
Widget tabs({required bool hideLeadingIcon}) => TickerMode(
|
||||
enabled: !collapsed,
|
||||
child: RepaintBoundary(
|
||||
child: MornyeTabBar(
|
||||
@@ -117,15 +118,15 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
selectedIndex: searchSelected ? -1 : selectedIndex,
|
||||
onSelected: onSelected,
|
||||
blurEnabled: blurEnabled,
|
||||
hideHomeIcon: hideHomeIcon,
|
||||
hiddenIconIndex: hideLeadingIcon ? leadingIndex : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
// At rest the glass bar must paint its own Home icon: its selected layer
|
||||
// At rest the glass bar must paint its own active icon: its selected layer
|
||||
// follows a dragged pill before the destination is committed. Hand them
|
||||
// to the moving overlays only while folding, at the same coordinates.
|
||||
final fullTabs = tabs(hideHomeIcon: false);
|
||||
final foldingTabs = tabs(hideHomeIcon: true);
|
||||
final fullTabs = tabs(hideLeadingIcon: false);
|
||||
final foldingTabs = tabs(hideLeadingIcon: true);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Match the tab's actual label height, including accessibility scaling.
|
||||
@@ -149,8 +150,8 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
final fullIconStart =
|
||||
tabInset +
|
||||
(constraints.maxWidth - 76 - tabInset * 2) /
|
||||
mainDestinations.length /
|
||||
2;
|
||||
mainDestinations.length *
|
||||
(leadingIndex + 0.5);
|
||||
final fullSearchBottom = glassTabs
|
||||
? 8.0
|
||||
: (math.max(64.0, 49 + labelHeight) - 64) / 2;
|
||||
@@ -161,28 +162,31 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
: const Duration(milliseconds: 380),
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, amount, _) {
|
||||
Widget movingIcon({required bool home, required Widget surface}) {
|
||||
final index = home ? 0 : destinations.length - 1;
|
||||
Widget movingIcon({
|
||||
required bool leading,
|
||||
required Widget surface,
|
||||
}) {
|
||||
final index = leading ? leadingIndex : destinations.length - 1;
|
||||
final offset = (fullIconStart - 26) * (1 - amount);
|
||||
final size = home ? 52.0 : 64 - 12 * amount;
|
||||
final size = leading ? 52.0 : 64 - 12 * amount;
|
||||
return PositionedDirectional(
|
||||
start: home ? offset : null,
|
||||
end: home ? null : 0,
|
||||
bottom: home
|
||||
start: leading ? offset : null,
|
||||
end: leading ? null : 0,
|
||||
bottom: leading
|
||||
? fullIconBottom * (1 - amount) + 34 * amount - 26
|
||||
: fullSearchBottom * (1 - amount) + 8 * amount,
|
||||
width: size,
|
||||
height: size,
|
||||
child: IgnorePointer(
|
||||
ignoring: home && amount < 0.5,
|
||||
ignoring: leading && amount < 0.5,
|
||||
child: ExcludeSemantics(
|
||||
excluding: home && amount < 0.5,
|
||||
excluding: leading && amount < 0.5,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: home ? amount : 1,
|
||||
opacity: leading ? amount : 1,
|
||||
child: surface,
|
||||
),
|
||||
),
|
||||
@@ -190,12 +194,12 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
key: ValueKey(
|
||||
home
|
||||
? 'mornye-compact-home'
|
||||
leading
|
||||
? 'mornye-compact-leading'
|
||||
: 'mornye-compact-search',
|
||||
),
|
||||
tooltip: home
|
||||
? context.l10n.navHome
|
||||
tooltip: leading
|
||||
? destinations[leadingIndex].label
|
||||
: context.l10n.mornyeSearch,
|
||||
iconSize: 25,
|
||||
constraints: BoxConstraints.tightFor(
|
||||
@@ -210,10 +214,14 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
amount,
|
||||
),
|
||||
icon: Opacity(
|
||||
opacity: home && amount == 0 ? 0 : 1,
|
||||
opacity: leading && amount == 0 ? 0 : 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
onPressed: home ? onHome : onSearch,
|
||||
onPressed: leading
|
||||
? leadingIndex == 0
|
||||
? onHome
|
||||
: () => onSelected(leadingIndex)
|
||||
: onSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -279,8 +287,8 @@ class MornyeBottomBar extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
movingIcon(home: true, surface: homeSurface),
|
||||
movingIcon(home: false, surface: searchSurface),
|
||||
movingIcon(leading: true, surface: leadingSurface),
|
||||
movingIcon(leading: false, surface: searchSurface),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget {
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
required this.blurEnabled,
|
||||
this.hideHomeIcon = false,
|
||||
this.hiddenIconIndex,
|
||||
});
|
||||
|
||||
final List<NavigationDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
final bool blurEnabled;
|
||||
// Home moves independently while the full tab capsule folds away.
|
||||
final bool hideHomeIcon;
|
||||
// The active icon moves independently while the full capsule folds away.
|
||||
final int? hiddenIconIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -535,7 +535,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
: inactiveIconColor,
|
||||
),
|
||||
child: Opacity(
|
||||
opacity: hideHomeIcon && index == 0 ? 0 : 1,
|
||||
opacity: hiddenIconIndex == index ? 0 : 1,
|
||||
child: destination.icon,
|
||||
),
|
||||
),
|
||||
@@ -635,7 +635,7 @@ class MornyeTabBar extends StatelessWidget {
|
||||
// Keep tab selection quiet, as in Mornye. Badges
|
||||
// stay live without the Material bounce/spin.
|
||||
child: Opacity(
|
||||
opacity: hideHomeIcon && index == 0 ? 0 : 1,
|
||||
opacity: hiddenIconIndex == index ? 0 : 1,
|
||||
child: destinations[index].icon,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,6 +23,21 @@ class ProfileAvatar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// ListTile can constrain height more than width. Keep the clipped
|
||||
// image square even when the surrounding slot is rectangular.
|
||||
final diameter = constraints.biggest.shortestSide.clamp(0.0, size);
|
||||
return Align(
|
||||
widthFactor: 1,
|
||||
heightFactor: 1,
|
||||
child: _buildAvatar(context, diameter),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context, double diameter) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final fallback = ColoredBox(
|
||||
color: scheme.primaryContainer,
|
||||
@@ -30,13 +45,13 @@ class ProfileAvatar extends StatelessWidget {
|
||||
child: name.trim().isEmpty
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: size * 0.55,
|
||||
size: diameter * 0.55,
|
||||
color: scheme.onPrimaryContainer,
|
||||
)
|
||||
: Text(
|
||||
name.trim().characters.first.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.42,
|
||||
fontSize: diameter * 0.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -46,7 +61,7 @@ class ProfileAvatar extends StatelessWidget {
|
||||
return ExcludeSemantics(
|
||||
child: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
dimension: diameter,
|
||||
child: photo != null
|
||||
? Image.memory(
|
||||
photo!,
|
||||
@@ -57,8 +72,9 @@ class ProfileAvatar extends StatelessWidget {
|
||||
? Image.file(
|
||||
File(photoPath!),
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
cacheWidth:
|
||||
(diameter * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil(),
|
||||
errorBuilder: (_, _, _) => fallback,
|
||||
)
|
||||
: fallback,
|
||||
|
||||
Reference in New Issue
Block a user