feat: Shared Widgets — EnergyBadge, EnergyBar, PetBubble, BottomSheetScaffold
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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.0–1.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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (Mon–Sun) 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 Mon–Sun.
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user