mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-06 20:57:57 +02:00
fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash
- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens - edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon - convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget - queue: keep selection toolbar hidden until modal close animation finishes - 24-bit and In Library badges now use primary dynamic color - artist skeleton: remove duplicate name/listeners lines, keep cover placeholder - files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
This commit is contained in:
@@ -474,9 +474,12 @@ class GridSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist screen skeleton – mimics the artist page content below the header:
|
||||
/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then
|
||||
/// a horizontal-scroll album section.
|
||||
/// Artist screen skeleton – shown *below* the SliverAppBar header while the
|
||||
/// discography loads. Renders a cover placeholder (only when the header image
|
||||
/// isn't available yet), the "Popular" section (rank + cover 48x48 + title +
|
||||
/// badge + trailing), and the horizontal album sections. The artist name and
|
||||
/// listeners are intentionally omitted here since the header already shows them
|
||||
/// overlaid on the cover.
|
||||
class ArtistScreenSkeleton extends StatelessWidget {
|
||||
final int popularCount;
|
||||
final int albumCount;
|
||||
@@ -500,25 +503,16 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
if (showCoverHeader)
|
||||
SkeletonBox(
|
||||
width: screenWidth,
|
||||
height: screenWidth * 0.75,
|
||||
borderRadius: 0,
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: SkeletonBox(width: 180, height: 24, borderRadius: 4),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
|
||||
),
|
||||
if (showPopularSection) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: SkeletonBox(width: 110, height: 22, borderRadius: 4),
|
||||
),
|
||||
...List.generate(popularCount, (index) {
|
||||
return Padding(
|
||||
@@ -528,7 +522,7 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
child: Center(
|
||||
child: SkeletonBox(
|
||||
@@ -546,33 +540,31 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SkeletonBox(
|
||||
width: 110 + (index % 4) * 30,
|
||||
width: 120 + (index % 4) * 30,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SkeletonBox(
|
||||
width: 70 + (index % 3) * 15,
|
||||
height: 11,
|
||||
const SizedBox(height: 8),
|
||||
// Mimics the small "In Library" badge pill.
|
||||
const SkeletonBox(
|
||||
width: 64,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SkeletonBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SkeletonBox(width: 18, height: 18, borderRadius: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SkeletonBox(width: 120, height: 22, borderRadius: 4),
|
||||
),
|
||||
SizedBox(
|
||||
height: 190,
|
||||
@@ -606,6 +598,7 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:ffmpeg_kit_flutter_new_full/level.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -887,7 +888,12 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
if (_analyzing) {
|
||||
final isRescan = _data != null || _spectrogramImage != null;
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
@@ -945,10 +951,15 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
if (_data == null) {
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: _analyze,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
@@ -1000,6 +1011,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
image: _spectrogramImage!,
|
||||
sampleRate: data.sampleRate,
|
||||
maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2,
|
||||
duration: data.spectrum?.duration ?? data.duration,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -1272,7 +1284,12 @@ class _AudioInfoCard extends StatelessWidget {
|
||||
final nyquist = data.sampleRate / 2;
|
||||
|
||||
return Card(
|
||||
color: cs.surfaceContainerLow,
|
||||
elevation: 0,
|
||||
color: settingsGroupColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -1583,16 +1600,19 @@ class _SpectrogramView extends StatelessWidget {
|
||||
final ui.Image image;
|
||||
final int sampleRate;
|
||||
final double maxFreq;
|
||||
final double duration;
|
||||
|
||||
const _SpectrogramView({
|
||||
required this.image,
|
||||
required this.sampleRate,
|
||||
required this.maxFreq,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
const labelColor = Color(0xFFB5B5B5);
|
||||
|
||||
return Card(
|
||||
color: Colors.black,
|
||||
@@ -1600,13 +1620,60 @@ class _SpectrogramView extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 2.0,
|
||||
child: CustomPaint(
|
||||
painter: _ImagePainter(image),
|
||||
size: Size.infinite,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 10, 10, 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const leftGutter = 34.0;
|
||||
const bottomGutter = 18.0;
|
||||
final plotWidth = constraints.maxWidth - leftGutter;
|
||||
final plotHeight = plotWidth / 2.0;
|
||||
final totalHeight = plotHeight + bottomGutter;
|
||||
return SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: totalHeight,
|
||||
child: CustomPaint(
|
||||
painter: _SpectrogramPainter(
|
||||
image: image,
|
||||
maxFreqHz: maxFreq,
|
||||
durationSec: duration,
|
||||
labelColor: labelColor,
|
||||
gridColor: Colors.white.withValues(alpha: 0.10),
|
||||
),
|
||||
size: Size(constraints.maxWidth, totalHeight),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Intensity color legend (matches the spectrogram colormap).
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 0, 10, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Quiet',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
gradient: LinearGradient(colors: _legendColors()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Loud',
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
@@ -1627,27 +1694,149 @@ class _SpectrogramView extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Color> _legendColors() {
|
||||
return List.generate(20, (i) {
|
||||
final c = _spekColorRGB(i / 19.0);
|
||||
return Color.fromARGB(255, c[0], c[1], c[2]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ImagePainter extends CustomPainter {
|
||||
class _SpectrogramPainter extends CustomPainter {
|
||||
final ui.Image image;
|
||||
_ImagePainter(this.image);
|
||||
final double maxFreqHz;
|
||||
final double durationSec;
|
||||
final Color labelColor;
|
||||
final Color gridColor;
|
||||
|
||||
static const double leftGutter = 34;
|
||||
static const double bottomGutter = 18;
|
||||
|
||||
_SpectrogramPainter({
|
||||
required this.image,
|
||||
required this.maxFreqHz,
|
||||
required this.durationSec,
|
||||
required this.labelColor,
|
||||
required this.gridColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
paintImage(
|
||||
canvas: canvas,
|
||||
rect: Offset.zero & size,
|
||||
image: image,
|
||||
fit: BoxFit.contain,
|
||||
filterQuality: FilterQuality.medium,
|
||||
final plot = Rect.fromLTWH(
|
||||
leftGutter,
|
||||
0,
|
||||
size.width - leftGutter,
|
||||
size.height - bottomGutter,
|
||||
);
|
||||
if (plot.width <= 0 || plot.height <= 0) return;
|
||||
|
||||
// Spectrogram image.
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
plot,
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
|
||||
final gridPaint = Paint()
|
||||
..color = gridColor
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Frequency axis (Y): 0 Hz at the bottom, maxFreq at the top.
|
||||
final maxKHz = maxFreqHz / 1000.0;
|
||||
if (maxKHz > 0) {
|
||||
final stepKHz = _niceStepKHz(maxKHz);
|
||||
for (double fk = 0; fk <= maxKHz + 0.001; fk += stepKHz) {
|
||||
final ratio = (fk * 1000) / maxFreqHz;
|
||||
final y = plot.bottom - ratio * plot.height;
|
||||
canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
fk == 0 ? '0' : '${fk.toStringAsFixed(0)}k',
|
||||
Offset(plot.left - 5, y),
|
||||
align: _TextAlignV.rightCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Time axis (X): 0 at the left, duration at the right.
|
||||
if (durationSec > 0) {
|
||||
final stepSec = _niceStepSec(durationSec);
|
||||
for (double ts = 0; ts <= durationSec + 0.001; ts += stepSec) {
|
||||
final ratio = ts / durationSec;
|
||||
final x = plot.left + ratio * plot.width;
|
||||
canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), gridPaint);
|
||||
_drawText(
|
||||
canvas,
|
||||
_fmtTime(ts),
|
||||
Offset(x, plot.bottom + 3),
|
||||
align: _TextAlignV.topCenter,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
String text,
|
||||
Offset anchor, {
|
||||
required _TextAlignV align,
|
||||
}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: labelColor, fontSize: 10),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
double dx = anchor.dx;
|
||||
double dy = anchor.dy;
|
||||
switch (align) {
|
||||
case _TextAlignV.rightCenter:
|
||||
dx = anchor.dx - tp.width;
|
||||
dy = anchor.dy - tp.height / 2;
|
||||
break;
|
||||
case _TextAlignV.topCenter:
|
||||
dx = anchor.dx - tp.width / 2;
|
||||
dy = anchor.dy;
|
||||
break;
|
||||
}
|
||||
tp.paint(canvas, Offset(dx, dy));
|
||||
}
|
||||
|
||||
static double _niceStepKHz(double maxKHz) {
|
||||
const candidates = [1.0, 2.0, 5.0, 10.0, 20.0, 50.0];
|
||||
for (final c in candidates) {
|
||||
if (maxKHz / c <= 6) return c;
|
||||
}
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
static double _niceStepSec(double dur) {
|
||||
const candidates = [5.0, 10.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0];
|
||||
for (final c in candidates) {
|
||||
if (dur / c <= 6) return c;
|
||||
}
|
||||
return 1200.0;
|
||||
}
|
||||
|
||||
static String _fmtTime(double sec) {
|
||||
final s = sec.round();
|
||||
final m = s ~/ 60;
|
||||
final r = s % 60;
|
||||
return '$m:${r.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ImagePainter old) => old.image != image;
|
||||
bool shouldRepaint(covariant _SpectrogramPainter old) =>
|
||||
old.image != image ||
|
||||
old.maxFreqHz != maxFreqHz ||
|
||||
old.durationSec != durationSec;
|
||||
}
|
||||
|
||||
enum _TextAlignV { rightCenter, topCenter }
|
||||
|
||||
class _SpectrogramRenderParams {
|
||||
final SpectrogramData spectrum;
|
||||
final int width;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
/// Modern, card-based batch convert sheet shared by the queue and album
|
||||
/// screens. It mirrors the single-track convert sheet styling so format and
|
||||
/// bitrate selection look consistent across the app.
|
||||
class BatchConvertSheet extends StatefulWidget {
|
||||
/// Available target formats.
|
||||
final List<String> formats;
|
||||
|
||||
/// Sheet title.
|
||||
final String title;
|
||||
|
||||
/// Optional subtitle shown under the title (e.g. number of tracks).
|
||||
final String? subtitle;
|
||||
|
||||
/// Label for the primary action button.
|
||||
final String confirmLabel;
|
||||
|
||||
/// Called with the selected format and bitrate when the user confirms.
|
||||
final void Function(String format, String bitrate) onConvert;
|
||||
|
||||
const BatchConvertSheet({
|
||||
super.key,
|
||||
required this.formats,
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
required this.onConvert,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BatchConvertSheet> createState() => _BatchConvertSheetState();
|
||||
}
|
||||
|
||||
class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
||||
static const _bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
late String _selectedFormat;
|
||||
late bool _isLosslessTarget;
|
||||
late String _selectedBitrate;
|
||||
|
||||
String _defaultBitrateForFormat(String format) {
|
||||
if (format == 'Opus') return '128k';
|
||||
if (format == 'AAC') return '256k';
|
||||
return '320k';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedFormat = widget.formats.first;
|
||||
_isLosslessTarget = isLosslessConversionTarget(_selectedFormat);
|
||||
_selectedBitrate = _isLosslessTarget
|
||||
? '320k'
|
||||
: _defaultBitrateForFormat(_selectedFormat);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_card(
|
||||
cs,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.formats.map((format) {
|
||||
return _choice(
|
||||
cs,
|
||||
label: format,
|
||||
selected: format == _selectedFormat,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedFormat = format;
|
||||
_isLosslessTarget = isLosslessConversionTarget(
|
||||
format,
|
||||
);
|
||||
if (!_isLosslessTarget) {
|
||||
_selectedBitrate = _defaultBitrateForFormat(
|
||||
format,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isLosslessTarget)
|
||||
_card(
|
||||
cs,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel(cs, context.l10n.trackConvertBitrate),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _bitrates.map((br) {
|
||||
return _choice(
|
||||
cs,
|
||||
label: br,
|
||||
selected: br == _selectedBitrate,
|
||||
onTap: () => setState(() => _selectedBitrate = br),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLosslessTarget)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.verified, size: 18, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () =>
|
||||
widget.onConvert(_selectedFormat, _selectedBitrate),
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
label: Text(widget.confirmLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ColorScheme cs, {required Widget child}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: settingsGroupColor(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionLabel(ColorScheme cs, String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _choice(
|
||||
ColorScheme cs, {
|
||||
required String label,
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: selected ? cs.primaryContainer : cs.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.transparent
|
||||
: cs.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: selected ? cs.onPrimaryContainer : cs.onSurface,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Background fill for grouped cards, matching the Settings group look. Blends a
|
||||
/// translucent overlay over the surface so it stays visible on AMOLED (pure
|
||||
/// black) dark themes as well as normal light/dark themes.
|
||||
Color settingsGroupColor(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
@@ -8,24 +25,17 @@ class SettingsGroup extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cardColor = settingsGroupColor(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: Color.alphaBlend(
|
||||
Colors.black.withValues(alpha: 0.04),
|
||||
colorScheme.surface,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
|
||||
Reference in New Issue
Block a user