redesign: duolingo-pattern onboarding, no gradient, pressable button

- Nunito font replacing Plus Jakarta Sans
- SButton: Stack-based 3D pressable (dark shadow layer + top layer translates on press)
- SCard: flat border-based selection, no shadows
- SProgressBar: 4px thin bar, animated fill
- All screens: white bg, no gradients
- Duolingo layout: illustration top zone, content below, sticky button
This commit is contained in:
mav
2026-05-28 16:34:04 +02:00
parent 30cee5a486
commit 6311088256
9 changed files with 1168 additions and 638 deletions

88
lib/widgets/s_button.dart Normal file
View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme.dart';
// Duolingo-style pressable button.
// Stack of two containers: dark "shadow" bottom + colored top that translates down on press.
class SButton extends StatefulWidget {
final String label;
final VoidCallback? onTap;
final bool enabled;
final Color? color;
final Color? shadowColor;
final Color? textColor;
const SButton({
super.key,
required this.label,
required this.onTap,
this.enabled = true,
this.color,
this.shadowColor,
this.textColor,
});
@override
State<SButton> createState() => _SButtonState();
}
class _SButtonState extends State<SButton> {
bool _pressed = false;
bool get _active => widget.enabled && widget.onTap != null;
static const _shadowH = 4.0;
static const _btnH = 52.0;
static const _radius = 16.0;
@override
Widget build(BuildContext context) {
final bg = _active ? (widget.color ?? SColors.primary) : const Color(0xFFE5E5E5);
final shadow = _active ? (widget.shadowColor ?? SColors.primaryDark) : const Color(0xFFB8B8B8);
final textCol = _active ? (widget.textColor ?? Colors.white) : SColors.textDisabled;
final offset = (_pressed && _active) ? _shadowH : 0.0;
return GestureDetector(
onTapDown: (_) { if (_active) setState(() => _pressed = true); },
onTapUp: (_) {
if (!_active) return;
setState(() => _pressed = false);
HapticFeedback.mediumImpact();
widget.onTap!();
},
onTapCancel: () { if (_active) setState(() => _pressed = false); },
child: SizedBox(
height: _btnH + _shadowH,
child: Stack(
children: [
// Shadow layer (always at bottom)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: shadow,
borderRadius: BorderRadius.circular(_radius),
),
),
),
// Button layer (translates down on press, "sinking" into shadow)
AnimatedPositioned(
duration: const Duration(milliseconds: 80),
top: offset,
left: 0,
right: 0,
height: _btnH,
child: Container(
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(_radius),
),
alignment: Alignment.center,
child: Text(widget.label, style: SText.button(textCol)),
),
),
],
),
),
);
}
}

111
lib/widgets/s_card.dart Normal file
View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme.dart';
// Duolingo-style option card. Flat — no shadow, border-based selection.
class SCard extends StatelessWidget {
final IconData icon;
final String label;
final String sub;
final bool isSelected;
final VoidCallback onTap;
const SCard({
super.key,
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.sub = '',
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
height: sub.isEmpty ? 62 : null,
padding: sub.isEmpty
? const EdgeInsets.symmetric(horizontal: 14)
: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: isSelected ? SColors.primaryLight : SColors.bgSurface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? SColors.primaryBorder : SColors.borderLight,
width: isSelected ? 2.0 : 1.5,
),
),
child: Row(
children: [
// Icon box
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? SColors.primary.withAlpha(20)
: const Color(0xFFF7F7F7),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 22,
color: isSelected ? SColors.primary : SColors.textBody,
),
),
const SizedBox(width: 12),
// Labels
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: SText.cardLabel(
isSelected ? SColors.primary : SColors.textHead,
),
),
if (sub.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
sub,
style: SText.cardSub(SColors.textSub),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 10),
// Radio
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? SColors.primary : Colors.transparent,
border: Border.all(
color: isSelected ? SColors.primary : SColors.borderMedium,
width: 1.5,
),
),
child: isSelected
? const Icon(Icons.check, size: 13, color: Colors.white)
: null,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../theme.dart';
class SProgressBar extends StatelessWidget {
final double value; // 0.0 to 1.0
const SProgressBar({super.key, required this.value});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Container(
height: 4,
width: constraints.maxWidth,
decoration: BoxDecoration(
color: SColors.borderLight,
borderRadius: BorderRadius.circular(2),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
height: 4,
width: constraints.maxWidth * value.clamp(0.0, 1.0),
decoration: BoxDecoration(
color: SColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
],
);
},
);
}
}