feat: Shared Widgets — EnergyBadge, EnergyBar, PetBubble, BottomSheetScaffold

This commit is contained in:
TrochtaOndrej
2026-03-18 04:35:50 +01:00
parent 03e1ab1d24
commit eafe559d3f
7 changed files with 661 additions and 0 deletions
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
/// Reusable bottom sheet with drag handle, progress dots, title, content,
/// and action buttons.
///
/// Use with [showModalBottomSheet] or as a child of [DraggableScrollableSheet].
class BottomSheetScaffold extends StatelessWidget {
const BottomSheetScaffold({
super.key,
required this.title,
required this.content,
this.subtitle,
this.totalSteps,
this.currentStep,
this.primaryButtonText,
this.onPrimaryPressed,
this.secondaryButtonText,
this.onSecondaryPressed,
});
final String title;
final String? subtitle;
final Widget content;
/// Total number of progress dots. Hidden if null.
final int? totalSteps;
/// Zero-based index of the active dot.
final int? currentStep;
final String? primaryButtonText;
final VoidCallback? onPrimaryPressed;
final String? secondaryButtonText;
final VoidCallback? onSecondaryPressed;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.creamBorder,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
// Progress dots
if (totalSteps != null && currentStep != null) ...[
_ProgressDots(total: totalSteps!, current: currentStep!),
const SizedBox(height: 16),
],
// Title
Text(title, style: KittieTypography.h2, textAlign: TextAlign.center),
// Subtitle
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 20),
// Content
content,
const SizedBox(height: 24),
// Primary button
if (primaryButtonText != null)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPrimaryPressed,
child: Text(primaryButtonText!),
),
),
// Secondary text button
if (secondaryButtonText != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: onSecondaryPressed,
child: Text(secondaryButtonText!),
),
],
],
),
),
);
}
}
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.total, required this.current});
final int total;
final int current;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(total, (i) {
final isActive = i == current;
return AnimatedContainer(
duration: AppConstants.animationFast,
width: isActive ? 10 : 8,
height: isActive ? 10 : 8,
margin: EdgeInsets.only(left: i > 0 ? 6 : 0),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? KittieColors.amber : KittieColors.creamBorder,
),
);
}),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
/// Energy level for the badge display.
enum EnergyLevel {
low(1),
medium(2),
high(3);
const EnergyLevel(this.value);
final int value;
/// Creates an [EnergyLevel] from an integer (1, 2, or 3).
factory EnergyLevel.fromInt(int level) => switch (level) {
1 => EnergyLevel.low,
2 => EnergyLevel.medium,
3 => EnergyLevel.high,
_ => EnergyLevel.low,
};
}
/// Small colored pill showing the energy level of a task.
///
/// - low: green bg, green text, leaf icon — "Lehke"
/// - medium: orange bg, orange text, sun icon — "Stredni"
/// - high: red bg, red text, fire icon — "Narocne"
class EnergyBadge extends StatelessWidget {
const EnergyBadge({super.key, required this.level});
final EnergyLevel level;
/// Convenience constructor from raw int (1/2/3).
factory EnergyBadge.fromInt(int level) =>
EnergyBadge(level: EnergyLevel.fromInt(level));
@override
Widget build(BuildContext context) {
final (bgColor, textColor, icon, label) = _props;
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: textColor),
const SizedBox(width: 4),
Text(
label,
style: KittieTypography.labelSmall.copyWith(color: textColor),
),
],
),
);
}
(Color bg, Color text, IconData icon, String label) get _props =>
switch (level) {
EnergyLevel.low => (
KittieColors.greenLight,
KittieColors.greenDark,
Icons.eco_outlined,
'Lehke',
),
EnergyLevel.medium => (
KittieColors.orangeLight,
KittieColors.orange,
Icons.wb_sunny_outlined,
'Stredni',
),
EnergyLevel.high => (
KittieColors.redLight,
KittieColors.red,
Icons.local_fire_department_outlined,
'Narocne',
),
};
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
/// Horizontal energy bar with "ENERGIE" label.
///
/// Shows a track with creamDark background and amber fill
/// that animates to the current [fraction] (0.01.0).
class EnergyBar extends StatelessWidget {
const EnergyBar({
super.key,
required this.fraction,
this.label = 'ENERGIE',
}) : assert(fraction >= 0.0 && fraction <= 1.0);
/// Fill amount from 0.0 (empty) to 1.0 (full).
final double fraction;
/// Label displayed above the bar.
final String label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
letterSpacing: 1.2,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
height: 8,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Track background
Container(
width: constraints.maxWidth,
color: KittieColors.creamDark,
),
// Amber fill
AnimatedContainer(
duration: AppConstants.animationNormal,
curve: Curves.easeInOut,
width: constraints.maxWidth * fraction,
color: KittieColors.amber,
),
],
);
},
),
),
),
],
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
/// Pet type determines the displayed emoji.
enum PetType {
dog('\u{1F436}'),
cat('\u{1F431}'),
capybara('\u{1F9AB}');
const PetType(this.emoji);
final String emoji;
}
/// Pet area card with a speech bubble.
///
/// Shows the pet emoji on the left and a white speech bubble on the right
/// containing the pet name, mood, and a message.
class PetBubble extends StatelessWidget {
const PetBubble({
super.key,
required this.petType,
required this.petName,
required this.mood,
required this.message,
});
final PetType petType;
final String petName;
final String mood;
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: KittieColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pet emoji
Text(
petType.emoji,
style: const TextStyle(fontSize: 48),
),
const SizedBox(width: 12),
// Speech bubble
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
petName,
style: KittieTypography.labelLarge,
),
const SizedBox(width: 6),
Text(
mood,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
),
),
],
),
const SizedBox(height: 4),
Text(
message,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
),
),
],
),
),
),
],
),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
/// Day status for a single streak dot.
enum DayStatus { done, today, future }
/// A row of 7 day-dots (MonSun) showing weekly task completion streak.
///
/// Colors:
/// - done: green (#7BAE82)
/// - today: amber (#C4A882)
/// - future: grey (#E0DBD0)
///
/// Shows fire emoji + streak count above the dots.
class StreakDots extends StatelessWidget {
const StreakDots({
super.key,
required this.days,
required this.streakCount,
}) : assert(days.length == 7, 'days must have exactly 7 entries');
/// Status for each day MonSun.
final List<DayStatus> days;
/// Current consecutive-day streak count.
final int streakCount;
static const _dayLabels = ['Po', 'Ut', 'St', 'Ct', 'Pa', 'So', 'Ne'];
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Streak header
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('\u{1F525}', style: TextStyle(fontSize: 16)),
const SizedBox(width: 4),
Text(
'$streakCount',
style: KittieTypography.labelLarge.copyWith(
color: KittieColors.brown,
),
),
],
),
const SizedBox(height: 8),
// Dot row
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
return Padding(
padding: EdgeInsets.only(left: i > 0 ? 8 : 0),
child: _DayDot(label: _dayLabels[i], status: days[i]),
);
}),
),
],
);
}
}
class _DayDot extends StatelessWidget {
const _DayDot({required this.label, required this.status});
final String label;
final DayStatus status;
@override
Widget build(BuildContext context) {
final color = switch (status) {
DayStatus.done => KittieColors.sageGreen,
DayStatus.today => KittieColors.amber,
DayStatus.future => KittieColors.creamBorder,
};
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(height: 4),
Text(
label,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
fontSize: 10,
),
),
],
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import 'energy_badge.dart';
/// A task list item with circular checkbox, title, energy badge, and time.
///
/// Features:
/// - Circular checkbox with haptic feedback on toggle
/// - Title with strikethrough + reduced opacity when done
/// - Subtitle row: [EnergyBadge] + optional time text
/// - Purple "Pece o sebe" tag when [isSelfCare] is true
/// - Red tint overlay when [isOverdue] is true
/// - Minimum height of 56px (Apple HIG touch target)
class TaskCard extends StatelessWidget {
const TaskCard({
super.key,
required this.title,
required this.energyLevel,
this.isDone = false,
this.isSelfCare = false,
this.isOverdue = false,
this.timeText,
this.onToggle,
this.onTap,
});
final String title;
final int energyLevel;
final bool isDone;
final bool isSelfCare;
final bool isOverdue;
final String? timeText;
final VoidCallback? onToggle;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: AppConstants.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isOverdue
? KittieColors.redLight
: KittieColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
children: [
_Checkbox(isDone: isDone, onToggle: onToggle),
const SizedBox(width: 12),
Expanded(child: _Content(
title: title,
energyLevel: energyLevel,
isDone: isDone,
isSelfCare: isSelfCare,
timeText: timeText,
)),
],
),
),
);
}
}
class _Checkbox extends StatelessWidget {
const _Checkbox({required this.isDone, this.onToggle});
final bool isDone;
final VoidCallback? onToggle;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
onToggle?.call();
},
child: AnimatedContainer(
duration: AppConstants.animationFast,
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone ? KittieColors.sageGreen : Colors.transparent,
border: Border.all(
color: isDone ? KittieColors.sageGreen : KittieColors.brownLight,
width: 2,
),
),
child: isDone
? const Icon(Icons.check, size: 16, color: Colors.white)
: null,
),
);
}
}
class _Content extends StatelessWidget {
const _Content({
required this.title,
required this.energyLevel,
required this.isDone,
required this.isSelfCare,
this.timeText,
});
final String title;
final int energyLevel;
final bool isDone;
final bool isSelfCare;
final String? timeText;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDefaultTextStyle(
duration: AppConstants.animationFast,
style: KittieTypography.bodyMedium.copyWith(
decoration: isDone ? TextDecoration.lineThrough : null,
color: isDone
? KittieColors.brown.withValues(alpha: 0.4)
: KittieColors.brown,
),
child: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
),
const SizedBox(height: 4),
Row(
children: [
EnergyBadge.fromInt(energyLevel),
if (isSelfCare) ...[
const SizedBox(width: 6),
_SelfCareTag(),
],
if (timeText != null) ...[
const SizedBox(width: 8),
Text(
timeText!,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
),
),
],
],
),
],
);
}
}
class _SelfCareTag extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.purpleLight,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'Pece o sebe',
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.purple,
),
),
),
);
}
}