feat: implement 7-skin theme system with persistent selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TrochtaOndrej
2026-03-21 18:11:47 +01:00
parent 6e0a7e9760
commit 326a9dbcee
24 changed files with 1441 additions and 488 deletions
+16 -1
View File
@@ -119,5 +119,20 @@
"common.retry": "Zkusit znovu",
"language.title": "Jazyk",
"language.en": "Angličtina",
"language.cs": "Čeština"
"language.cs": "Čeština",
"skin.title": "Vzhled aplikace",
"skin.defaultSkin": "Výchozí",
"skin.defaultSkin_desc": "Teplý pergamen",
"skin.accessibility": "Přístupný",
"skin.accessibility_desc": "Vysoký kontrast",
"skin.senior": "Senior",
"skin.senior_desc": "Velký text",
"skin.modern": "Moderní",
"skin.modern_desc": "Čistý a minimální",
"skin.kids": "Dětský",
"skin.kids_desc": "Barevný a hravý",
"skin.epic": "Epický",
"skin.epic_desc": "Tmavý a výrazný",
"skin.future": "Budoucnost",
"skin.future_desc": "Neonové vibrace"
}
+16 -1
View File
@@ -119,5 +119,20 @@
"common.retry": "Retry",
"language.title": "Language",
"language.en": "English",
"language.cs": "Czech"
"language.cs": "Czech",
"skin.title": "App skin",
"skin.defaultSkin": "Default",
"skin.defaultSkin_desc": "Warm parchment",
"skin.accessibility": "Accessible",
"skin.accessibility_desc": "High contrast",
"skin.senior": "Senior",
"skin.senior_desc": "Large text",
"skin.modern": "Modern",
"skin.modern_desc": "Clean & minimal",
"skin.kids": "Kids",
"skin.kids_desc": "Colorful & fun",
"skin.epic": "Epic",
"skin.epic_desc": "Dark & bold",
"skin.future": "Future",
"skin.future_desc": "Neon vibes"
}
+39 -40
View File
@@ -1,30 +1,31 @@
import 'package:flutter/material.dart';
import 'kittie_colors.dart';
import 'kittie_typography.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
import 'skin_typography.dart';
import '../constants/app_constants.dart';
/// Material 3 theme for KittieMobile.
abstract final class KittieTheme {
static ThemeData get light {
final colorScheme = ColorScheme.light(
surface: KittieColors.cream,
onSurface: KittieColors.brownDark,
primary: KittieColors.brown,
onPrimary: KittieColors.cream,
secondary: KittieColors.amber,
onSecondary: KittieColors.brownDark,
tertiary: KittieColors.sageGreen,
onTertiary: KittieColors.cream,
error: KittieColors.red,
onError: KittieColors.cream,
);
/// Legacy light theme — uses the default parchment colors.
static ThemeData get light => fromSkin(
DefaultSkinColors(),
SkinTypography(
colors: DefaultSkinColors(),
skinType: SkinType.defaultSkin,
),
);
/// Builds a full [ThemeData] from [SkinColors] and [SkinTypography].
static ThemeData fromSkin(SkinColors colors, SkinTypography typography) {
final cs = colors.toColorScheme();
final typo = typography.textTheme;
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: KittieTypography.textTheme,
scaffoldBackgroundColor: KittieColors.cream,
colorScheme: cs,
textTheme: typo,
scaffoldBackgroundColor: cs.surface,
// AppBar
appBarTheme: AppBarTheme(
@@ -32,35 +33,33 @@ abstract final class KittieTheme {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: KittieTypography.h2.copyWith(
color: KittieColors.brownDark,
),
iconTheme: const IconThemeData(color: KittieColors.brownDark),
titleTextStyle: typo.headlineMedium,
iconTheme: IconThemeData(color: cs.onSurface),
),
// Card
cardTheme: CardThemeData(
color: KittieColors.cream,
color: cs.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
side: const BorderSide(color: KittieColors.creamBorder),
side: BorderSide(color: cs.onSurface.withValues(alpha: 0.12)),
),
),
// Bottom Navigation Bar
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: KittieColors.cream,
selectedItemColor: KittieColors.brown,
unselectedItemColor: KittieColors.brownLight,
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: cs.surface,
selectedItemColor: cs.primary,
unselectedItemColor: cs.onSurface.withValues(alpha: 0.5),
elevation: 0,
type: BottomNavigationBarType.fixed,
),
// FAB
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: KittieColors.brown,
foregroundColor: KittieColors.cream,
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
@@ -68,9 +67,9 @@ abstract final class KittieTheme {
),
// Bottom Sheet
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: KittieColors.cream,
shape: RoundedRectangleBorder(
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
@@ -80,14 +79,14 @@ abstract final class KittieTheme {
// Input Decoration
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: KittieColors.creamDark,
fillColor: colors.creamDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: KittieColors.brown, width: 1.5),
borderSide: BorderSide(color: cs.primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -98,13 +97,13 @@ abstract final class KittieTheme {
// Elevated Button
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.brown,
foregroundColor: KittieColors.cream,
backgroundColor: cs.primary,
foregroundColor: cs.onPrimary,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: KittieTypography.labelLarge,
textStyle: typo.labelLarge,
elevation: 0,
),
),
@@ -112,8 +111,8 @@ abstract final class KittieTheme {
// Text Button
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: KittieColors.brown,
textStyle: KittieTypography.labelLarge,
foregroundColor: cs.primary,
textStyle: typo.labelLarge,
),
),
);
+511
View File
@@ -0,0 +1,511 @@
import 'package:flutter/material.dart';
/// Abstract base class for all skin color palettes.
///
/// Each skin provides concrete color values used throughout the app.
/// Dark skins (Epic, Future) MUST override [toColorScheme] with
/// [ColorScheme.dark] to prevent white-on-white text bugs.
abstract class SkinColors {
// ── Surface / cream ────────────────────────────────────────────────────────
Color get cream;
Color get creamDark;
Color get creamBorder;
// ── Brown / primary ────────────────────────────────────────────────────────
Color get brown;
Color get brownLight;
Color get brownDark;
Color get brownMid;
// ── Sage green / tertiary ──────────────────────────────────────────────────
Color get sageGreen;
Color get greenLight;
Color get greenDark;
// ── Amber / secondary ──────────────────────────────────────────────────────
Color get amber;
// ── Purple / accent ────────────────────────────────────────────────────────
Color get purple;
Color get purpleLight;
// ── Red / error ────────────────────────────────────────────────────────────
Color get red;
Color get redLight;
// ── Orange / warning ───────────────────────────────────────────────────────
Color get orange;
Color get orangeLight;
// ── Energy level colors ────────────────────────────────────────────────────
Color get energyLow;
Color get energyMedium;
Color get energyHigh;
/// Builds the [ColorScheme] for this skin.
///
/// Light skins use [ColorScheme.light]; dark skins (epic, future)
/// MUST override with [ColorScheme.dark].
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: cream,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: cream,
error: red,
onError: cream,
);
}
// ── Default (Warm Parchment) ─────────────────────────────────────────────────
class DefaultSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF5F2EC);
@override
Color get creamDark => const Color(0xFFEDE8DF);
@override
Color get creamBorder => const Color(0xFFE0DBD0);
@override
Color get brown => const Color(0xFF6B5A48);
@override
Color get brownLight => const Color(0xFF8C8070);
@override
Color get brownDark => const Color(0xFF2C2820);
@override
Color get brownMid => const Color(0xFF3D3830);
@override
Color get sageGreen => const Color(0xFF7BAE82);
@override
Color get greenLight => const Color(0xFFEEF4EF);
@override
Color get greenDark => const Color(0xFF3A6040);
@override
Color get amber => const Color(0xFFC4A882);
@override
Color get purple => const Color(0xFF9070A0);
@override
Color get purpleLight => const Color(0xFFF0E8F8);
@override
Color get red => const Color(0xFFC45858);
@override
Color get redLight => const Color(0xFFFAF0F0);
@override
Color get orange => const Color(0xFFC4943A);
@override
Color get orangeLight => const Color(0xFFFBF3E8);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => orange;
@override
Color get energyHigh => red;
}
// ── Accessibility (WCAG AAA High-Contrast) ───────────────────────────────────
class AccessibilitySkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFFFFFFF);
@override
Color get creamDark => const Color(0xFFF0F0F0);
@override
Color get creamBorder => const Color(0xFFCCCCCC);
@override
Color get brown => const Color(0xFF0055CC);
@override
Color get brownLight => const Color(0xFF444444);
@override
Color get brownDark => const Color(0xFF1A1A1A);
@override
Color get brownMid => const Color(0xFF333333);
@override
Color get sageGreen => const Color(0xFF006600);
@override
Color get greenLight => const Color(0xFFE0F0E0);
@override
Color get greenDark => const Color(0xFF004400);
@override
Color get amber => const Color(0xFF996600);
@override
Color get purple => const Color(0xFF660099);
@override
Color get purpleLight => const Color(0xFFEEDDFF);
@override
Color get red => const Color(0xFFCC0000);
@override
Color get redLight => const Color(0xFFFFEEEE);
@override
Color get orange => const Color(0xFFCC5500);
@override
Color get orangeLight => const Color(0xFFFFEEDD);
@override
Color get energyLow => const Color(0xFF006600);
@override
Color get energyMedium => const Color(0xFFCC5500);
@override
Color get energyHigh => const Color(0xFFCC0000);
@override
ColorScheme toColorScheme() => const ColorScheme.light(
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF1A1A1A),
primary: Color(0xFF0055CC),
onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFF996600),
onSecondary: Color(0xFFFFFFFF),
tertiary: Color(0xFF006600),
onTertiary: Color(0xFFFFFFFF),
error: Color(0xFFCC0000),
onError: Color(0xFFFFFFFF),
);
}
// ── Senior (off-white, navy, gold, 1.25x) ────────────────────────────────────
class SeniorSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFFAFAFA);
@override
Color get creamDark => const Color(0xFFF0EFE8);
@override
Color get creamBorder => const Color(0xFFDDDDD5);
@override
Color get brown => const Color(0xFF2C4A7C);
@override
Color get brownLight => const Color(0xFF6B7A99);
@override
Color get brownDark => const Color(0xFF1C1C1C);
@override
Color get brownMid => const Color(0xFF3A3A3A);
@override
Color get sageGreen => const Color(0xFF3A7D44);
@override
Color get greenLight => const Color(0xFFE8F5E9);
@override
Color get greenDark => const Color(0xFF1B5E20);
@override
Color get amber => const Color(0xFFB8942C);
@override
Color get purple => const Color(0xFF6A3D9A);
@override
Color get purpleLight => const Color(0xFFF0E6FF);
@override
Color get red => const Color(0xFFB71C1C);
@override
Color get redLight => const Color(0xFFFFEBEE);
@override
Color get orange => const Color(0xFFE65100);
@override
Color get orangeLight => const Color(0xFFFFF3E0);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Modern (cool gray, teal) ──────────────────────────────────────────────────
class ModernSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF8F9FA);
@override
Color get creamDark => const Color(0xFFECEFF1);
@override
Color get creamBorder => const Color(0xFFCFD8DC);
@override
Color get brown => const Color(0xFF0D9488);
@override
Color get brownLight => const Color(0xFF64748B);
@override
Color get brownDark => const Color(0xFF212529);
@override
Color get brownMid => const Color(0xFF374151);
@override
Color get sageGreen => const Color(0xFF10B981);
@override
Color get greenLight => const Color(0xFFD1FAE5);
@override
Color get greenDark => const Color(0xFF065F46);
@override
Color get amber => const Color(0xFFF59E0B);
@override
Color get purple => const Color(0xFF8B5CF6);
@override
Color get purpleLight => const Color(0xFFEDE9FE);
@override
Color get red => const Color(0xFFEF4444);
@override
Color get redLight => const Color(0xFFFEE2E2);
@override
Color get orange => const Color(0xFFF97316);
@override
Color get orangeLight => const Color(0xFFFFEDD5);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Kids (lavender, pink, sunny yellow, 1.1x) ────────────────────────────────
class KidsSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFFF0E6FF);
@override
Color get creamDark => const Color(0xFFE3D5F5);
@override
Color get creamBorder => const Color(0xFFD1B8F0);
@override
Color get brown => const Color(0xFFE91E8F);
@override
Color get brownLight => const Color(0xFF9966CC);
@override
Color get brownDark => const Color(0xFF3D1F6E);
@override
Color get brownMid => const Color(0xFF5D3091);
@override
Color get sageGreen => const Color(0xFF4CAF50);
@override
Color get greenLight => const Color(0xFFE8F5E9);
@override
Color get greenDark => const Color(0xFF1B5E20);
@override
Color get amber => const Color(0xFFFFD93D);
@override
Color get purple => const Color(0xFF9C27B0);
@override
Color get purpleLight => const Color(0xFFF3E5F5);
@override
Color get red => const Color(0xFFF44336);
@override
Color get redLight => const Color(0xFFFFEBEE);
@override
Color get orange => const Color(0xFFFF9800);
@override
Color get orangeLight => const Color(0xFFFFF3E0);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
@override
ColorScheme toColorScheme() => ColorScheme.light(
surface: cream,
onSurface: brownDark,
primary: brown,
onPrimary: Colors.white,
secondary: amber,
onSecondary: brownDark,
tertiary: sageGreen,
onTertiary: Colors.white,
error: red,
onError: Colors.white,
);
}
// ── Epic (dark purple, gold) ──────────────────────────────────────────────────
class EpicSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFF1A1425);
@override
Color get creamDark => const Color(0xFF241C35);
@override
Color get creamBorder => const Color(0xFF3D3050);
@override
Color get brown => const Color(0xFF7C3AED);
@override
Color get brownLight => const Color(0xFFAA88DD);
@override
Color get brownDark => const Color(0xFFE8E0F0);
@override
Color get brownMid => const Color(0xFFCCBBEE);
@override
Color get sageGreen => const Color(0xFF10B981);
@override
Color get greenLight => const Color(0xFF1A2E28);
@override
Color get greenDark => const Color(0xFF6EE7B7);
@override
Color get amber => const Color(0xFFF59E0B);
@override
Color get purple => const Color(0xFFA855F7);
@override
Color get purpleLight => const Color(0xFF2D1B4E);
@override
Color get red => const Color(0xFFF87171);
@override
Color get redLight => const Color(0xFF2D1414);
@override
Color get orange => const Color(0xFFFB923C);
@override
Color get orangeLight => const Color(0xFF2D1F0A);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
/// Epic is a dark skin — MUST use [ColorScheme.dark].
@override
ColorScheme toColorScheme() => const ColorScheme.dark(
surface: Color(0xFF1A1425),
onSurface: Color(0xFFE8E0F0),
primary: Color(0xFF7C3AED),
onPrimary: Color(0xFFFFFFFF),
secondary: Color(0xFFF59E0B),
onSecondary: Color(0xFF1A1425),
tertiary: Color(0xFF10B981),
onTertiary: Color(0xFF1A1425),
error: Color(0xFFF87171),
onError: Color(0xFF1A1425),
);
}
// ── Future (neon cyan/blue, very dark) ───────────────────────────────────────
class FutureSkinColors extends SkinColors {
@override
Color get cream => const Color(0xFF0A0E17);
@override
Color get creamDark => const Color(0xFF111827);
@override
Color get creamBorder => const Color(0xFF1F2D3D);
@override
Color get brown => const Color(0xFF2979FF);
@override
Color get brownLight => const Color(0xFF5599FF);
@override
Color get brownDark => const Color(0xFF00E5FF);
@override
Color get brownMid => const Color(0xFF99CCFF);
@override
Color get sageGreen => const Color(0xFF00E5FF);
@override
Color get greenLight => const Color(0xFF0A1A1F);
@override
Color get greenDark => const Color(0xFF00BCD4);
@override
Color get amber => const Color(0xFFFF6D00);
@override
Color get purple => const Color(0xFFFF1744);
@override
Color get purpleLight => const Color(0xFF1A0A10);
@override
Color get red => const Color(0xFFFF1744);
@override
Color get redLight => const Color(0xFF1A0A10);
@override
Color get orange => const Color(0xFFFF6D00);
@override
Color get orangeLight => const Color(0xFF1A100A);
@override
Color get energyLow => sageGreen;
@override
Color get energyMedium => amber;
@override
Color get energyHigh => red;
/// Future is a dark skin — MUST use [ColorScheme.dark].
@override
ColorScheme toColorScheme() => const ColorScheme.dark(
surface: Color(0xFF0A0E17),
onSurface: Color(0xFF00E5FF),
primary: Color(0xFF2979FF),
onPrimary: Color(0xFF0A0E17),
secondary: Color(0xFFFF6D00),
onSecondary: Color(0xFF0A0E17),
tertiary: Color(0xFF00E5FF),
onTertiary: Color(0xFF0A0E17),
error: Color(0xFFFF1744),
onError: Color(0xFF0A0E17),
);
}
+30 -19
View File
@@ -1,33 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_skin.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
import 'skin_typography.dart';
const _kSkinKey = 'app_skin';
/// Provides the currently active [AppSkin].
///
/// Initialised to [AppSkin.defaultSkin]; call [loadSaved] at startup to
/// restore the user's persisted choice.
final skinProvider = NotifierProvider<SkinNotifier, AppSkin>(SkinNotifier.new);
/// Module-level initial skin — set before [runApp] via [setInitialSkin].
SkinType _initialSkin = SkinType.defaultSkin;
class SkinNotifier extends Notifier<AppSkin> {
/// Sets the initial skin from persisted preferences before [runApp].
void setInitialSkin(SkinType skin) => _initialSkin = skin;
/// Provides the currently active [SkinType].
///
/// Initialised to the value set by [setInitialSkin] (or [SkinType.defaultSkin]
/// if not called). Use [SkinNotifier.setSkin] to switch and persist.
final skinProvider = NotifierProvider<SkinNotifier, SkinType>(SkinNotifier.new);
class SkinNotifier extends Notifier<SkinType> {
@override
AppSkin build() => AppSkin.defaultSkin;
SkinType build() => _initialSkin;
/// Switches to [skin] and persists the choice.
Future<void> setSkin(AppSkin skin) async {
Future<void> setSkin(SkinType skin) async {
state = skin;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kSkinKey, skin.key);
}
/// Restores the previously persisted skin from SharedPreferences.
Future<void> loadSaved() async {
final prefs = await SharedPreferences.getInstance();
final key = prefs.getString(_kSkinKey);
if (key != null) {
state = AppSkin.fromKey(key);
}
await prefs.setString(_kSkinKey, skin.storageKey);
}
}
/// Provides the [SkinColors] for the currently active skin.
final skinColorsProvider = Provider<SkinColors>(
(ref) => ref.watch(skinProvider).colors,
);
/// Provides the [SkinTypography] for the currently active skin.
final skinTypographyProvider = Provider<SkinTypography>((ref) {
final skinType = ref.watch(skinProvider);
final colors = ref.watch(skinColorsProvider);
return SkinTypography(colors: colors, skinType: skinType);
});
+40
View File
@@ -0,0 +1,40 @@
import 'skin_colors.dart';
/// All available app skins.
enum SkinType {
defaultSkin,
accessibility,
senior,
modern,
kids,
epic,
future;
}
extension SkinTypeExtension on SkinType {
/// i18n key for the skin name.
String get nameKey => 'skin.$name';
/// i18n key for the skin description.
String get descriptionKey => 'skin.${name}_desc';
/// Stable storage key for SharedPreferences persistence.
String get storageKey => name;
/// Parses a storage key back to [SkinType], defaulting to [SkinType.defaultSkin].
static SkinType fromStorageKey(String key) => SkinType.values.firstWhere(
(s) => s.storageKey == key,
orElse: () => SkinType.defaultSkin,
);
/// Returns the [SkinColors] instance for this skin.
SkinColors get colors => switch (this) {
SkinType.defaultSkin => DefaultSkinColors(),
SkinType.accessibility => AccessibilitySkinColors(),
SkinType.senior => SeniorSkinColors(),
SkinType.modern => ModernSkinColors(),
SkinType.kids => KidsSkinColors(),
SkinType.epic => EpicSkinColors(),
SkinType.future => FutureSkinColors(),
};
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'skin_colors.dart';
import 'skin_type.dart';
/// Typography system parameterized by [SkinColors] and [SkinType].
///
/// Font family:
/// - accessibility, senior → Atkinson Hyperlegible
/// - all others → Lexend
///
/// Scale factors:
/// - senior: 1.25×, kids: 1.1×, accessibility: 1.15×, others: 1.0×
class SkinTypography {
SkinTypography({required this.colors, required this.skinType});
final SkinColors colors;
final SkinType skinType;
double get _scale => switch (skinType) {
SkinType.accessibility => 1.15,
SkinType.senior => 1.25,
SkinType.kids => 1.1,
_ => 1.0,
};
TextStyle _s(double size, FontWeight w) {
final scaled = size * _scale;
return switch (skinType) {
SkinType.accessibility || SkinType.senior =>
GoogleFonts.atkinsonHyperlegible(
fontSize: scaled,
fontWeight: w,
color: colors.brownDark,
),
_ => GoogleFonts.lexend(
fontSize: scaled,
fontWeight: w,
color: colors.brownDark,
),
};
}
// ── Headings ────────────────────────────────────────────────────────────────
TextStyle get h1 => _s(28, FontWeight.w700);
TextStyle get h2 => _s(22, FontWeight.w600);
TextStyle get h3 => _s(18, FontWeight.w600);
// ── Body ────────────────────────────────────────────────────────────────────
TextStyle get bodyLarge => _s(16, FontWeight.w400);
TextStyle get bodyMedium => _s(14, FontWeight.w400);
TextStyle get bodySmall => _s(13, FontWeight.w400);
// ── Labels ──────────────────────────────────────────────────────────────────
TextStyle get labelLarge => _s(14, FontWeight.w500);
TextStyle get labelMedium => _s(12, FontWeight.w500);
TextStyle get labelSmall => _s(11, FontWeight.w500);
/// Full [TextTheme] for Material 3.
TextTheme get textTheme => TextTheme(
headlineLarge: h1,
headlineMedium: h2,
headlineSmall: h3,
bodyLarge: bodyLarge,
bodyMedium: bodyMedium,
bodySmall: bodySmall,
labelLarge: labelLarge,
labelMedium: labelMedium,
labelSmall: labelSmall,
titleLarge: h2,
titleMedium: h3,
titleSmall: labelLarge,
);
}
@@ -6,8 +6,7 @@ import 'package:uuid/uuid.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/i18n/translation_service.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../domain/entities/pet_state_entity.dart';
import '../../../domain/entities/pet_type.dart';
import '../../../shared/widgets/language_picker.dart';
@@ -57,7 +56,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
const uuid = Uuid();
final userId = uuid.v4();
final deviceId = '${defaultTargetPlatform.name.toLowerCase()}-${uuid.v4()}';
final deviceId =
'${defaultTargetPlatform.name.toLowerCase()}-${uuid.v4()}';
await repo.createUser(
id: userId,
@@ -85,33 +85,17 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
@override
Widget build(BuildContext context) {
final step = ref.watch(onboardingStepProvider);
final skinColors = ref.watch(skinColorsProvider);
return Scaffold(
backgroundColor: KittieColors.brownDark,
backgroundColor: skinColors.brownDark,
body: SafeArea(
child: Column(
children: [
// Progress dots
Padding(
padding: const EdgeInsets.only(top: 24, bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) {
final isActive = i == step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? KittieColors.amber
: KittieColors.brownLight,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
child: _ProgressDots(step: step, skinColors: skinColors),
),
// Pages
@@ -143,6 +127,32 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
}
}
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.step, required this.skinColors});
final int step;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) {
final isActive = i == step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive ? skinColors.amber : skinColors.brownLight,
borderRadius: BorderRadius.circular(4),
),
);
}),
);
}
}
// ─── Step 1: Welcome ────────────────────────────────────────────────────────
class _WelcomeStep extends ConsumerWidget {
@@ -152,6 +162,8 @@ class _WelcomeStep extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -164,14 +176,13 @@ class _WelcomeStep extends ConsumerWidget {
const SizedBox(height: 24),
Text(
t('onboarding.welcome_title'),
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
style: skinTypo.h1.copyWith(color: skinColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
t('onboarding.welcome_subtitle'),
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
style: skinTypo.bodyLarge.copyWith(color: skinColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -180,8 +191,8 @@ class _WelcomeStep extends ConsumerWidget {
child: ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
backgroundColor: skinColors.amber,
foregroundColor: skinColors.brownDark,
),
child: Text(t('onboarding.start')),
),
@@ -215,6 +226,8 @@ class _PetSelectionStep extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedPetTypeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -223,7 +236,7 @@ class _PetSelectionStep extends ConsumerWidget {
children: [
Text(
t('onboarding.choose_pet'),
style: KittieTypography.h2.copyWith(color: KittieColors.cream),
style: skinTypo.h2.copyWith(color: skinColors.cream),
),
const SizedBox(height: 32),
@@ -242,11 +255,12 @@ class _PetSelectionStep extends ConsumerWidget {
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.brownMid,
color: skinColors.brownMid,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSelected ? KittieColors.amber : Colors.transparent,
color: isSelected
? skinColors.amber
: Colors.transparent,
width: 2,
),
),
@@ -257,8 +271,9 @@ class _PetSelectionStep extends ConsumerWidget {
const SizedBox(height: 4),
Text(
t(pet.labelKey),
style: KittieTypography.labelSmall
.copyWith(color: KittieColors.cream),
style: skinTypo.labelSmall.copyWith(
color: skinColors.cream,
),
),
],
),
@@ -272,20 +287,20 @@ class _PetSelectionStep extends ConsumerWidget {
// Name input
Text(
t('onboarding.pet_name_label'),
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.cream),
style: skinTypo.bodyLarge.copyWith(color: skinColors.cream),
),
const SizedBox(height: 12),
TextField(
controller: nameController,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
style:
skinTypo.bodyLarge.copyWith(color: skinColors.brownDark),
textAlign: TextAlign.center,
decoration: InputDecoration(
fillColor: KittieColors.creamDark,
fillColor: skinColors.creamDark,
hintText: t('onboarding.pet_name_hint'),
hintStyle: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownLight),
hintStyle: skinTypo.bodyLarge.copyWith(
color: skinColors.brownLight,
),
),
onChanged: (value) {
ref.read(petNameProvider.notifier).update(value);
@@ -301,16 +316,17 @@ class _PetSelectionStep extends ConsumerWidget {
onPressed: onBack,
child: Text(
t('common.back'),
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
),
const Spacer(),
ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
backgroundColor: skinColors.amber,
foregroundColor: skinColors.brownDark,
),
child: Text(t('onboarding.continue')),
),
@@ -334,6 +350,8 @@ class _ReadyStep extends ConsumerWidget {
final petName = ref.watch(petNameProvider);
final petType = ref.watch(selectedPetTypeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final emoji = switch (petType) {
PetType.dog => '🐶',
@@ -346,23 +364,22 @@ class _ReadyStep extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
size: 80,
),
const SizedBox(height: 24),
Text(
t('onboarding.ready_title'),
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
style: skinTypo.h1.copyWith(color: skinColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
t('onboarding.ready_subtitle',
params: {'emoji': emoji, 'petName': petName}),
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
style: skinTypo.bodyLarge.copyWith(color: skinColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -372,16 +389,17 @@ class _ReadyStep extends ConsumerWidget {
onPressed: onBack,
child: Text(
t('common.back'),
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
),
const Spacer(),
ElevatedButton(
onPressed: onComplete,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
backgroundColor: skinColors.sageGreen,
foregroundColor: skinColors.cream,
),
child: Text(t('onboarding.start_using')),
),
+98 -68
View File
@@ -4,8 +4,7 @@ import 'package:uuid/uuid.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../domain/entities/task_entity.dart';
import '../providers/add_task_providers.dart';
import '../providers/pet_providers.dart';
@@ -81,8 +80,8 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
final int minute = time?.minute ?? 0;
return switch (day) {
TaskDay.today => DateTime(now.year, now.month, now.day, hour, minute),
TaskDay.tomorrow => DateTime(
now.year, now.month, now.day + 1, hour, minute),
TaskDay.tomorrow =>
DateTime(now.year, now.month, now.day + 1, hour, minute),
_ => null,
};
}
@@ -114,11 +113,12 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
Widget build(BuildContext context) {
final step = ref.watch(addTaskStepProvider);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final skinColors = ref.watch(skinColorsProvider);
return Container(
decoration: const BoxDecoration(
color: KittieColors.cream,
borderRadius: BorderRadius.vertical(
decoration: BoxDecoration(
color: skinColors.cream,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
),
@@ -136,34 +136,14 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
// Step dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final isActive = i == step;
final isPast = i < step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 20 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive
? KittieColors.brown
: isPast
? KittieColors.sageGreen
: KittieColors.creamBorder,
borderRadius: BorderRadius.circular(3),
),
);
}),
),
_StepDots(step: step),
const SizedBox(height: 20),
// Step content
@@ -188,8 +168,7 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
),
1 => _Step2When(
key: const ValueKey(1),
onNext: () =>
ref.read(addTaskStepProvider.notifier).next(),
onNext: () => ref.read(addTaskStepProvider.notifier).next(),
onBack: () =>
ref.read(addTaskStepProvider.notifier).previous(),
),
@@ -213,6 +192,38 @@ class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
}
}
class _StepDots extends ConsumerWidget {
const _StepDots({required this.step});
final int step;
@override
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(4, (i) {
final isActive = i == step;
final isPast = i < step;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 20 : 6,
height: 6,
decoration: BoxDecoration(
color: isActive
? skinColors.brown
: isPast
? skinColors.sageGreen
: skinColors.creamBorder,
borderRadius: BorderRadius.circular(3),
),
);
}),
);
}
}
// ─── Step 1: Title & Note ───────────────────────────────────────────────────
class _Step1Title extends ConsumerWidget {
@@ -230,28 +241,28 @@ class _Step1Title extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t('tasks.new_task'), style: KittieTypography.h2),
Text(t('tasks.new_task'), style: skinTypo.h2),
const SizedBox(height: 20),
Text(t('tasks.title_label'), style: KittieTypography.labelMedium),
Text(t('tasks.title_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
TextField(
controller: titleController,
autofocus: true,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
style: skinTypo.bodyLarge.copyWith(color: skinColors.brownDark),
decoration: InputDecoration(hintText: t('tasks.title_hint')),
),
const SizedBox(height: 16),
Text(t('tasks.note_label'), style: KittieTypography.labelMedium),
Text(t('tasks.note_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
TextField(
controller: noteController,
style: KittieTypography.bodyMedium
.copyWith(color: KittieColors.brownDark),
style: skinTypo.bodyMedium.copyWith(color: skinColors.brownDark),
maxLines: 3,
decoration: InputDecoration(hintText: t('tasks.note_hint')),
),
@@ -294,11 +305,13 @@ class _Step2When extends ConsumerWidget {
final selectedDay = ref.watch(selectedTaskDayProvider);
final selectedTime = ref.watch(selectedTaskTimeProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(t('tasks.when_title'), style: KittieTypography.h2),
Text(t('tasks.when_title'), style: skinTypo.h2),
const SizedBox(height: 20),
// Day chips
@@ -313,11 +326,10 @@ class _Step2When extends ConsumerWidget {
onSelected: (_) {
ref.read(selectedTaskDayProvider.notifier).select(d.day);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
selectedColor: skinColors.amber,
backgroundColor: skinColors.creamDark,
labelStyle: skinTypo.labelLarge.copyWith(
color: isSelected ? skinColors.brownDark : skinColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -331,7 +343,7 @@ class _Step2When extends ConsumerWidget {
const SizedBox(height: 24),
// Time presets
Text(t('tasks.time_label'), style: KittieTypography.labelMedium),
Text(t('tasks.time_label'), style: skinTypo.labelMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -345,11 +357,10 @@ class _Step2When extends ConsumerWidget {
onSelected: (_) {
ref.read(selectedTaskTimeProvider.notifier).select(time);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
selectedColor: skinColors.amber,
backgroundColor: skinColors.creamDark,
labelStyle: skinTypo.labelLarge.copyWith(
color: isSelected ? skinColors.brownDark : skinColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -391,16 +402,33 @@ class _Step3Energy extends ConsumerWidget {
final VoidCallback onSave;
final VoidCallback onBack;
static const _energyOptions = [
(level: 1, labelKey: 'tasks.energy_low', emoji: '🍃', color: KittieColors.sageGreen),
(level: 2, labelKey: 'tasks.energy_medium', emoji: '', color: KittieColors.orange),
(level: 3, labelKey: 'tasks.energy_high', emoji: '🔥', color: KittieColors.red),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedEnergyProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final energyOptions = [
(
level: 1,
labelKey: 'tasks.energy_low',
emoji: '🍃',
color: skinColors.sageGreen
),
(
level: 2,
labelKey: 'tasks.energy_medium',
emoji: '',
color: skinColors.orange
),
(
level: 3,
labelKey: 'tasks.energy_high',
emoji: '🔥',
color: skinColors.red
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -409,7 +437,7 @@ class _Step3Energy extends ConsumerWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
),
child: Row(
@@ -419,7 +447,7 @@ class _Step3Energy extends ConsumerWidget {
Expanded(
child: Text(
t('tasks.energy_question'),
style: KittieTypography.bodyLarge,
style: skinTypo.bodyLarge,
),
),
],
@@ -429,7 +457,7 @@ class _Step3Energy extends ConsumerWidget {
// Energy picker
Row(
children: _energyOptions.map((opt) {
children: energyOptions.map((opt) {
final isSelected = selected == opt.level;
return Expanded(
child: GestureDetector(
@@ -443,7 +471,7 @@ class _Step3Energy extends ConsumerWidget {
decoration: BoxDecoration(
color: isSelected
? opt.color.withValues(alpha: 0.15)
: KittieColors.creamDark,
: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? opt.color : Colors.transparent,
@@ -456,8 +484,8 @@ class _Step3Energy extends ConsumerWidget {
const SizedBox(height: 8),
Text(
t(opt.labelKey),
style: KittieTypography.labelLarge.copyWith(
color: isSelected ? opt.color : KittieColors.brown,
style: skinTypo.labelLarge.copyWith(
color: isSelected ? opt.color : skinColors.brown,
),
),
],
@@ -479,9 +507,9 @@ class _Step3Energy extends ConsumerWidget {
ElevatedButton(
onPressed: selected != null ? onSave : null,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
disabledBackgroundColor: KittieColors.creamDark,
backgroundColor: skinColors.sageGreen,
foregroundColor: skinColors.cream,
disabledBackgroundColor: skinColors.creamDark,
),
child: Text(t('tasks.save_task')),
),
@@ -507,16 +535,18 @@ class _Step4Confirmation extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
children: [
const Icon(
Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
size: 64,
),
const SizedBox(height: 16),
Text(t('tasks.task_saved'), style: KittieTypography.h2),
Text(t('tasks.task_saved'), style: skinTypo.h2),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
+46 -33
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/theme/skin_provider.dart';
import '../providers/pet_providers.dart';
import '../providers/task_providers.dart';
@@ -19,12 +18,15 @@ class PetScreen extends ConsumerWidget {
final level = ref.watch(petLevelProvider);
final selfCareCount = ref.watch(selfCareCountProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final unlocked = petState.unlockedItems.isEmpty
? <String>[]
: petState.unlockedItems.split(',');
return Scaffold(
backgroundColor: KittieColors.cream,
backgroundColor: skinColors.cream,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
@@ -33,14 +35,14 @@ class PetScreen extends ConsumerWidget {
// Pet name header
Text(
user.petName,
style: KittieTypography.h1.copyWith(fontSize: 24),
style: skinTypo.h1.copyWith(fontSize: 24),
),
const SizedBox(height: 4),
Text(
t('pet.companion_label',
params: {'petType': user.petType.label}),
style: KittieTypography.bodyMedium.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodyMedium.copyWith(
color: skinColors.brownLight,
),
),
const SizedBox(height: 24),
@@ -54,9 +56,9 @@ class PetScreen extends ConsumerWidget {
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: KittieColors.creamDark,
color: skinColors.creamDark,
border: Border.all(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
width: 3,
),
),
@@ -74,10 +76,10 @@ class PetScreen extends ConsumerWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
color: _moodColor(mood),
color: _moodColor(mood, skinColors),
shape: BoxShape.circle,
border: Border.all(
color: KittieColors.cream,
color: skinColors.cream,
width: 2,
),
),
@@ -107,25 +109,29 @@ class PetScreen extends ConsumerWidget {
label: t('pet.stat_streak'),
value: t('pet.stat_streak_value',
params: {'days': '${petState.streakDays}'}),
color: KittieColors.orange,
color: skinColors.orange,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.check_circle_rounded,
label: t('pet.stat_tasks_done'),
value: '${petState.totalTasksDone}',
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.favorite_rounded,
label: t('pet.stat_self_care'),
value: '$selfCareCount',
color: KittieColors.purple,
color: skinColors.purple,
skinTypo: skinTypo,
),
_StatTile(
icon: Icons.star_rounded,
label: t('pet.stat_level'),
value: '$level',
color: KittieColors.amber,
color: skinColors.amber,
skinTypo: skinTypo,
),
],
),
@@ -134,8 +140,7 @@ class PetScreen extends ConsumerWidget {
// Unlocked items header
Align(
alignment: Alignment.centerLeft,
child: Text(t('pet.unlocked_items'),
style: KittieTypography.h3),
child: Text(t('pet.unlocked_items'), style: skinTypo.h3),
),
const SizedBox(height: 12),
@@ -149,21 +154,29 @@ class PetScreen extends ConsumerWidget {
name: t('pet.item_bow'),
icon: Icons.catching_pokemon_rounded,
unlocked: unlocked.contains('masle'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: t('pet.item_bed'),
icon: Icons.bed_rounded,
unlocked: unlocked.contains('pelisek'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: t('pet.item_crown'),
icon: Icons.auto_awesome_rounded,
unlocked: unlocked.contains('koruna'),
skinColors: skinColors,
skinTypo: skinTypo,
),
_ItemCard(
name: t('pet.item_wings'),
icon: Icons.flight_rounded,
unlocked: unlocked.contains('kridla'),
skinColors: skinColors,
skinTypo: skinTypo,
),
],
),
@@ -177,10 +190,10 @@ class PetScreen extends ConsumerWidget {
);
}
Color _moodColor(PetMood mood) => switch (mood) {
PetMood.happy => KittieColors.sageGreen,
PetMood.neutral => KittieColors.amber,
PetMood.sad => KittieColors.red,
Color _moodColor(PetMood mood, dynamic skinColors) => switch (mood) {
PetMood.happy => skinColors.sageGreen,
PetMood.neutral => skinColors.amber,
PetMood.sad => skinColors.red,
};
}
@@ -191,12 +204,14 @@ class _StatTile extends StatelessWidget {
final String label;
final String value;
final Color color;
final dynamic skinTypo;
const _StatTile({
required this.icon,
required this.label,
required this.value,
required this.color,
required this.skinTypo,
});
@override
@@ -213,14 +228,8 @@ class _StatTile extends StatelessWidget {
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
value,
style: KittieTypography.h2.copyWith(color: color),
),
Text(
label,
style: KittieTypography.bodySmall.copyWith(color: color),
),
Text(value, style: skinTypo.h2.copyWith(color: color)),
Text(label, style: skinTypo.bodySmall.copyWith(color: color)),
],
),
);
@@ -231,11 +240,15 @@ class _ItemCard extends StatelessWidget {
final String name;
final IconData icon;
final bool unlocked;
final dynamic skinColors;
final dynamic skinTypo;
const _ItemCard({
required this.name,
required this.icon,
required this.unlocked,
required this.skinColors,
required this.skinTypo,
});
@override
@@ -246,9 +259,9 @@ class _ItemCard extends StatelessWidget {
width: 80,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KittieColors.creamBorder),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -256,19 +269,19 @@ class _ItemCard extends StatelessWidget {
Icon(
icon,
size: 32,
color: unlocked ? KittieColors.amber : KittieColors.brownLight,
color: unlocked ? skinColors.amber : skinColors.brownLight,
),
const SizedBox(height: 6),
Text(
name,
style: KittieTypography.labelSmall,
style: skinTypo.labelSmall,
textAlign: TextAlign.center,
),
if (!unlocked)
Icon(
Icons.lock_rounded,
size: 12,
color: KittieColors.brownLight,
color: skinColors.brownLight,
),
],
),
+21 -37
View File
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/skin_provider.dart';
import '../screens/add_task_screen.dart';
import '../../../domain/entities/pet_type.dart';
import '../../../domain/entities/task_status.dart';
@@ -29,6 +28,8 @@ class TasksScreen extends ConsumerWidget {
final petMood = ref.watch(petMoodProvider);
final userName = ref.watch(userNameProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
body: SafeArea(
@@ -37,10 +38,8 @@ class TasksScreen extends ConsumerWidget {
error: (e, _) =>
Center(child: Text(t('tasks.error', params: {'error': '$e'}))),
data: (tasks) {
final pendingTasks =
tasks.where((t) => !t.isCompleted).toList();
final completedTasks =
tasks.where((t) => t.isCompleted).toList();
final pendingTasks = tasks.where((t) => !t.isCompleted).toList();
final completedTasks = tasks.where((t) => t.isCompleted).toList();
final pendingCount = pendingTasks.length;
if (tasks.isEmpty) {
@@ -55,10 +54,8 @@ class TasksScreen extends ConsumerWidget {
// 1. Date header
Text(
localizedDateHeader(DateTime.now(), t),
style: GoogleFonts.lexend(
fontSize: 12,
fontWeight: FontWeight.w500,
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -67,10 +64,8 @@ class TasksScreen extends ConsumerWidget {
// 2. Greeting
Text(
'${localizedGreeting(t)}, $userName',
style: GoogleFonts.lexend(
fontSize: 22,
fontWeight: FontWeight.w600,
color: KittieColors.brownDark,
style: skinTypo.h2.copyWith(
color: skinColors.brownDark,
),
),
const SizedBox(height: 20),
@@ -100,10 +95,8 @@ class TasksScreen extends ConsumerWidget {
children: [
Text(
t('tasks.section_today'),
style: GoogleFonts.lexend(
fontSize: 12,
fontWeight: FontWeight.w600,
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -111,15 +104,14 @@ class TasksScreen extends ConsumerWidget {
Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: KittieColors.amber,
decoration: BoxDecoration(
color: skinColors.amber,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$pendingCount',
style: GoogleFonts.lexend(
fontSize: 11,
style: skinTypo.labelSmall.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
),
@@ -180,12 +172,9 @@ class TasksScreen extends ConsumerWidget {
String Function(String, {Map<String, String>? params}) t,
) {
return switch (mood) {
PetMood.happy =>
t('pet.mood_happy', params: {'petName': petName}),
PetMood.neutral =>
t('pet.mood_neutral', params: {'petName': petName}),
PetMood.sad =>
t('pet.mood_sad', params: {'petName': petName}),
PetMood.happy => t('pet.mood_happy', params: {'petName': petName}),
PetMood.neutral => t('pet.mood_neutral', params: {'petName': petName}),
PetMood.sad => t('pet.mood_sad', params: {'petName': petName}),
};
}
}
@@ -199,6 +188,8 @@ class _EmptyState extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final emoji = petType.emoji;
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Center(
child: Column(
@@ -208,19 +199,12 @@ class _EmptyState extends ConsumerWidget {
const SizedBox(height: 16),
Text(
t('tasks.empty_title'),
style: GoogleFonts.lexend(
fontSize: 18,
fontWeight: FontWeight.w600,
color: KittieColors.brownLight,
),
style: skinTypo.h3.copyWith(color: skinColors.brownLight),
),
const SizedBox(height: 8),
Text(
t('tasks.empty_subtitle'),
style: GoogleFonts.lexend(
fontSize: 14,
color: KittieColors.brownLight,
),
style: skinTypo.bodyMedium.copyWith(color: skinColors.brownLight),
),
],
),
+39 -23
View File
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/i18n/i18n_provider.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../../../core/theme/skin_provider.dart';
import '../../../shared/widgets/energy_badge.dart';
import '../../../domain/entities/task_entity.dart';
import '../../tasks/providers/task_providers.dart';
@@ -21,9 +20,11 @@ class ZenScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final task = ref.watch(nextZenTaskProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
backgroundColor: KittieColors.creamDark,
backgroundColor: skinColors.creamDark,
body: SafeArea(
child: Column(
children: [
@@ -34,7 +35,7 @@ class ZenScreen extends ConsumerWidget {
padding: const EdgeInsets.only(left: 8, top: 8),
child: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
color: KittieColors.brown,
color: skinColors.brown,
onPressed: () => context.pop(),
),
),
@@ -44,8 +45,8 @@ class ZenScreen extends ConsumerWidget {
Expanded(
child: Center(
child: task == null
? _buildEmpty(t)
: _buildTask(context, ref, task, t),
? _buildEmpty(t, skinColors, skinTypo)
: _buildTask(context, ref, task, t, skinColors, skinTypo),
),
),
@@ -58,20 +59,27 @@ class ZenScreen extends ConsumerWidget {
Expanded(
child: ElevatedButton(
onPressed: () {
ref.read(taskRepositoryProvider).completeTask(task.id);
ref.read(petStateProvider.notifier).incrementTasksDone();
ref
.read(taskRepositoryProvider)
.completeTask(task.id);
ref
.read(petStateProvider.notifier)
.incrementTasksDone();
},
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
backgroundColor: skinColors.sageGreen,
foregroundColor: Colors.white,
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(t('zen.done'),
style: KittieTypography.labelLarge
.copyWith(color: Colors.white)),
child: Text(
t('zen.done'),
style: skinTypo.labelLarge.copyWith(
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
@@ -80,22 +88,24 @@ class ZenScreen extends ConsumerWidget {
onPressed: () {
ref.read(taskRepositoryProvider).updateTask(
task.copyWith(
sortOrder: task.sortOrder + 100),
sortOrder: task.sortOrder + 100,
),
);
},
style: TextButton.styleFrom(
foregroundColor: KittieColors.brownLight,
foregroundColor: skinColors.brownLight,
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(
color: KittieColors.creamBorder,
),
side: BorderSide(color: skinColors.creamBorder),
),
),
child: Text(
t('zen.skip'),
style: skinTypo.labelLarge.copyWith(
color: skinColors.brownLight,
),
),
child: Text(t('zen.skip'),
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight)),
),
),
],
@@ -107,7 +117,11 @@ class ZenScreen extends ConsumerWidget {
);
}
Widget _buildEmpty(String Function(String, {Map<String, String>? params}) t) {
Widget _buildEmpty(
String Function(String, {Map<String, String>? params}) t,
dynamic skinColors,
dynamic skinTypo,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -116,7 +130,7 @@ class ZenScreen extends ConsumerWidget {
Text(
t('zen.all_done_title'),
textAlign: TextAlign.center,
style: KittieTypography.h2.copyWith(color: KittieColors.brown),
style: skinTypo.h2.copyWith(color: skinColors.brown),
),
],
);
@@ -127,6 +141,8 @@ class ZenScreen extends ConsumerWidget {
WidgetRef ref,
TaskEntity task,
String Function(String, {Map<String, String>? params}) t,
dynamic skinColors,
dynamic skinTypo,
) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -136,7 +152,7 @@ class ZenScreen extends ConsumerWidget {
Text(
task.title,
textAlign: TextAlign.center,
style: KittieTypography.h1.copyWith(fontSize: 28),
style: skinTypo.h1.copyWith(fontSize: 28),
),
const SizedBox(height: 16),
EnergyBadge(energyLevel: task.energyLevel),
+12 -1
View File
@@ -6,10 +6,19 @@ import 'core/i18n/i18n_provider.dart';
import 'core/i18n/translation_service.dart';
import 'core/router/app_router.dart';
import 'core/theme/kittie_theme.dart';
import 'core/theme/skin_provider.dart';
import 'core/theme/skin_type.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
// Restore saved skin before runApp so the first frame uses the right theme.
final savedSkinKey = prefs.getString('app_skin');
if (savedSkinKey != null) {
setInitialSkin(SkinTypeExtension.fromStorageKey(savedSkinKey));
}
final savedLocale = prefs.getString('app_locale') ?? 'cs';
await TranslationService.instance.init(locale: savedLocale);
runApp(const ProviderScope(child: KittieApp()));
@@ -22,10 +31,12 @@ class KittieApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(localeProvider);
final router = ref.watch(appRouterProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return MaterialApp.router(
title: 'KittieMobile',
theme: KittieTheme.light,
theme: KittieTheme.fromSkin(skinColors, skinTypo),
routerConfig: router,
debugShowCheckedModeBanner: false,
);
+24 -9
View File
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
import '../../core/constants/app_constants.dart';
/// Bottom navigation shell wrapping the main tabs.
@@ -16,15 +15,17 @@ class AppShell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
body: navigationShell,
bottomNavigationBar: Container(
height: AppConstants.bottomNavHeight,
decoration: const BoxDecoration(
color: KittieColors.cream,
decoration: BoxDecoration(
color: skinColors.cream,
border: Border(
top: BorderSide(color: KittieColors.creamBorder, width: 0.5),
top: BorderSide(color: skinColors.creamBorder, width: 0.5),
),
),
child: Row(
@@ -35,18 +36,24 @@ class AppShell extends ConsumerWidget {
label: t('nav.tasks'),
isActive: navigationShell.currentIndex == 0,
onTap: () => navigationShell.goBranch(0),
skinColors: skinColors,
skinTypo: skinTypo,
),
_NavItem(
icon: Icons.pets_rounded,
label: t('nav.pet'),
isActive: navigationShell.currentIndex == 1,
onTap: () => navigationShell.goBranch(1),
skinColors: skinColors,
skinTypo: skinTypo,
),
_NavItem(
icon: Icons.person_rounded,
label: t('nav.profile'),
isActive: navigationShell.currentIndex == 2,
onTap: () => navigationShell.goBranch(2),
skinColors: skinColors,
skinTypo: skinTypo,
),
],
),
@@ -60,17 +67,22 @@ class _NavItem extends StatelessWidget {
final String label;
final bool isActive;
final VoidCallback onTap;
final dynamic skinColors;
final dynamic skinTypo;
const _NavItem({
required this.icon,
required this.label,
required this.isActive,
required this.onTap,
required this.skinColors,
required this.skinTypo,
});
@override
Widget build(BuildContext context) {
final color = isActive ? KittieColors.brown : KittieColors.brownLight;
final color =
isActive ? skinColors.brown : skinColors.brownLight;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
@@ -82,14 +94,17 @@ class _NavItem extends StatelessWidget {
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 2),
Text(label, style: KittieTypography.labelSmall.copyWith(color: color)),
Text(
label,
style: skinTypo.labelSmall.copyWith(color: color),
),
const SizedBox(height: 4),
if (isActive)
Container(
width: 4,
height: 4,
decoration: const BoxDecoration(
color: KittieColors.amber,
decoration: BoxDecoration(
color: skinColors.amber,
shape: BoxShape.circle,
),
),
+27 -11
View File
@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.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 {
class BottomSheetScaffold extends ConsumerWidget {
const BottomSheetScaffold({
super.key,
required this.title,
@@ -38,7 +38,10 @@ class BottomSheetScaffold extends StatelessWidget {
final VoidCallback? onSecondaryPressed;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
@@ -50,7 +53,7 @@ class BottomSheetScaffold extends StatelessWidget {
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.creamBorder,
color: skinColors.creamBorder,
borderRadius: BorderRadius.circular(2),
),
),
@@ -58,20 +61,28 @@ class BottomSheetScaffold extends StatelessWidget {
// Progress dots
if (totalSteps != null && currentStep != null) ...[
_ProgressDots(total: totalSteps!, current: currentStep!),
_ProgressDots(
total: totalSteps!,
current: currentStep!,
skinColors: skinColors,
),
const SizedBox(height: 16),
],
// Title
Text(title, style: KittieTypography.h2, textAlign: TextAlign.center),
Text(
title,
style: skinTypo.h2,
textAlign: TextAlign.center,
),
// Subtitle
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
textAlign: TextAlign.center,
),
@@ -110,10 +121,15 @@ class BottomSheetScaffold extends StatelessWidget {
}
class _ProgressDots extends StatelessWidget {
const _ProgressDots({required this.total, required this.current});
const _ProgressDots({
required this.total,
required this.current,
required this.skinColors,
});
final int total;
final int current;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
@@ -128,7 +144,7 @@ class _ProgressDots extends StatelessWidget {
margin: EdgeInsets.only(left: i > 0 ? 6 : 0),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? KittieColors.amber : KittieColors.creamBorder,
color: isActive ? skinColors.amber : skinColors.creamBorder,
),
);
}),
+26 -26
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
/// Energy level for the badge display.
enum EnergyLevel {
@@ -48,7 +47,30 @@ class EnergyBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final (bgColor, textColor, icon, labelKey) = _props;
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final (bgColor, textColor, icon, labelKey) = switch (_effectiveLevel) {
EnergyLevel.low => (
skinColors.greenLight,
skinColors.greenDark,
Icons.eco_outlined,
'energy.low',
),
EnergyLevel.medium => (
skinColors.orangeLight,
skinColors.orange,
Icons.wb_sunny_outlined,
'energy.medium',
),
EnergyLevel.high => (
skinColors.redLight,
skinColors.red,
Icons.local_fire_department_outlined,
'energy.high',
),
};
final label = t(labelKey);
return Container(
@@ -65,32 +87,10 @@ class EnergyBadge extends ConsumerWidget {
const SizedBox(width: 4),
Text(
label,
style: KittieTypography.labelSmall.copyWith(color: textColor),
style: skinTypo.labelSmall.copyWith(color: textColor),
),
],
),
);
}
(Color bg, Color text, IconData icon, String labelKey) get _props =>
switch (_effectiveLevel) {
EnergyLevel.low => (
KittieColors.greenLight,
KittieColors.greenDark,
Icons.eco_outlined,
'energy.low',
),
EnergyLevel.medium => (
KittieColors.orangeLight,
KittieColors.orange,
Icons.wb_sunny_outlined,
'energy.medium',
),
EnergyLevel.high => (
KittieColors.redLight,
KittieColors.red,
Icons.local_fire_department_outlined,
'energy.high',
),
};
}
+12 -9
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
/// Horizontal energy bar with "ENERGIE" label.
/// Horizontal energy bar with label.
///
/// Shows a track with creamDark background and amber fill
/// that animates to the current [fraction] (0.01.0).
/// Alternatively, pass [energyLevel] (13) to compute the fraction.
class EnergyBar extends StatelessWidget {
class EnergyBar extends ConsumerWidget {
const EnergyBar({
super.key,
this.fraction,
@@ -33,15 +33,18 @@ class EnergyBar extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
letterSpacing: 1.2,
),
),
@@ -57,14 +60,14 @@ class EnergyBar extends StatelessWidget {
// Track background
Container(
width: constraints.maxWidth,
color: KittieColors.creamDark,
color: skinColors.creamDark,
),
// Amber fill
AnimatedContainer(
duration: AppConstants.animationNormal,
curve: Curves.easeInOut,
width: constraints.maxWidth * _effectiveFraction,
color: KittieColors.amber,
color: skinColors.amber,
),
],
);
+16 -12
View File
@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
import '../../domain/entities/pet_type.dart';
/// Pet area card with a speech bubble.
///
/// Shows the pet emoji on the left and a white speech bubble on the right
/// Shows the pet emoji on the left and a speech bubble on the right
/// containing the pet name, mood, and a message.
class PetBubble extends StatelessWidget {
class PetBubble extends ConsumerWidget {
const PetBubble({
super.key,
required this.petType,
@@ -24,11 +24,14 @@ class PetBubble extends StatelessWidget {
final String message;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
@@ -45,8 +48,9 @@ class PetBubble extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
color: skinColors.cream,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -56,13 +60,13 @@ class PetBubble extends StatelessWidget {
children: [
Text(
petName,
style: KittieTypography.labelLarge,
style: skinTypo.labelLarge,
),
const SizedBox(width: 6),
Text(
mood,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
),
),
],
@@ -70,8 +74,8 @@ class PetBubble extends StatelessWidget {
const SizedBox(height: 4),
Text(
message,
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
),
],
+79 -36
View File
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
import '../../features/tasks/providers/pet_providers.dart';
import '../widgets/language_picker.dart';
import '../widgets/skin_picker.dart';
/// User profile screen — name, pet info, stats, export.
/// User profile screen — name, pet info, stats, language, skin.
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
@@ -17,9 +17,11 @@ class ProfileScreen extends ConsumerWidget {
final petState = ref.watch(petStateProvider);
final level = ref.watch(petLevelProvider);
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Scaffold(
backgroundColor: KittieColors.cream,
backgroundColor: skinColors.cream,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
@@ -29,7 +31,7 @@ class ProfileScreen extends ConsumerWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t('profile.title'), style: KittieTypography.h2),
Text(t('profile.title'), style: skinTypo.h2),
const LanguagePicker(),
],
),
@@ -45,7 +47,11 @@ class ProfileScreen extends ConsumerWidget {
initialValue: user.name,
onSave: (v) => ref.read(userProvider.notifier).updateName(v),
t: t,
skinColors: skinColors,
skinTypo: skinTypo,
),
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 16),
@@ -60,7 +66,11 @@ class ProfileScreen extends ConsumerWidget {
onSave: (v) =>
ref.read(userProvider.notifier).updatePetName(v),
t: t,
skinColors: skinColors,
skinTypo: skinTypo,
),
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 16),
@@ -68,19 +78,21 @@ class ProfileScreen extends ConsumerWidget {
_InfoRow(
label: t('profile.pet_type_label'),
value: user.petType.label,
skinColors: skinColors,
skinTypo: skinTypo,
),
const SizedBox(height: 32),
// Stats summary
Text(t('profile.stats_title'), style: KittieTypography.h3),
Text(t('profile.stats_title'), style: skinTypo.h3),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: KittieColors.creamDark,
color: skinColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KittieColors.creamBorder),
border: Border.all(color: skinColors.creamBorder),
),
child: Column(
children: [
@@ -89,27 +101,36 @@ class ProfileScreen extends ConsumerWidget {
label: t('pet.stat_streak'),
value: t('pet.stat_streak_value',
params: {'days': '${petState.streakDays}'}),
color: KittieColors.orange,
color: skinColors.orange,
skinTypo: skinTypo,
),
const Divider(color: KittieColors.creamBorder, height: 24),
Divider(color: skinColors.creamBorder, height: 24),
_StatRow(
icon: Icons.check_circle_rounded,
label: t('pet.stat_tasks_done'),
value: '${petState.totalTasksDone}',
color: KittieColors.sageGreen,
color: skinColors.sageGreen,
skinTypo: skinTypo,
),
const Divider(color: KittieColors.creamBorder, height: 24),
Divider(color: skinColors.creamBorder, height: 24),
_StatRow(
icon: Icons.star_rounded,
label: t('pet.stat_level'),
value: '$level',
color: KittieColors.amber,
color: skinColors.amber,
skinTypo: skinTypo,
),
],
),
),
const SizedBox(height: 32),
// Skin picker section
Text(t('skin.title'), style: skinTypo.h3),
const SizedBox(height: 12),
const SkinPicker(),
const SizedBox(height: 32),
// Export button
SizedBox(
width: double.infinity,
@@ -117,15 +138,18 @@ class ProfileScreen extends ConsumerWidget {
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(t('profile.export_preparing'))),
content: Text(t('profile.export_preparing')),
),
);
},
icon: const Icon(Icons.download_rounded),
label: Text(t('profile.export_data'),
style: KittieTypography.labelLarge),
label: Text(
t('profile.export_data'),
style: skinTypo.labelLarge,
),
style: OutlinedButton.styleFrom(
foregroundColor: KittieColors.brown,
side: const BorderSide(color: KittieColors.creamBorder),
foregroundColor: skinColors.brown,
side: BorderSide(color: skinColors.creamBorder),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -139,8 +163,8 @@ class ProfileScreen extends ConsumerWidget {
Center(
child: Text(
t('profile.version'),
style: KittieTypography.bodySmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.bodySmall.copyWith(
color: skinColors.brownLight,
),
),
),
@@ -158,17 +182,19 @@ class ProfileScreen extends ConsumerWidget {
required String initialValue,
required ValueChanged<String> onSave,
required String Function(String, {Map<String, String>? params}) t,
required dynamic skinColors,
required dynamic skinTypo,
}) {
final controller = TextEditingController(text: initialValue);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: KittieColors.cream,
title: Text(title, style: KittieTypography.h3),
backgroundColor: skinColors.cream,
title: Text(title, style: skinTypo.h3),
content: TextField(
controller: controller,
autofocus: true,
style: KittieTypography.bodyLarge,
style: skinTypo.bodyLarge,
),
actions: [
TextButton(
@@ -195,11 +221,15 @@ class _EditableRow extends StatelessWidget {
final String label;
final String value;
final VoidCallback onEdit;
final dynamic skinColors;
final dynamic skinTypo;
const _EditableRow({
required this.label,
required this.value,
required this.onEdit,
required this.skinColors,
required this.skinTypo,
});
@override
@@ -210,17 +240,20 @@ class _EditableRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: KittieTypography.labelMedium
.copyWith(color: KittieColors.brownLight)),
Text(
label,
style: skinTypo.labelMedium.copyWith(
color: skinColors.brownLight,
),
),
const SizedBox(height: 4),
Text(value, style: KittieTypography.bodyLarge),
Text(value, style: skinTypo.bodyLarge),
],
),
),
IconButton(
icon: const Icon(Icons.edit_rounded, size: 20),
color: KittieColors.brownLight,
color: skinColors.brownLight,
onPressed: onEdit,
),
],
@@ -231,19 +264,28 @@ class _EditableRow extends StatelessWidget {
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final dynamic skinColors;
final dynamic skinTypo;
const _InfoRow({required this.label, required this.value});
const _InfoRow({
required this.label,
required this.value,
required this.skinColors,
required this.skinTypo,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: KittieTypography.labelMedium
.copyWith(color: KittieColors.brownLight)),
Text(
label,
style:
skinTypo.labelMedium.copyWith(color: skinColors.brownLight),
),
const SizedBox(height: 4),
Text(value, style: KittieTypography.bodyLarge),
Text(value, style: skinTypo.bodyLarge),
],
);
}
@@ -254,12 +296,14 @@ class _StatRow extends StatelessWidget {
final String label;
final String value;
final Color color;
final dynamic skinTypo;
const _StatRow({
required this.icon,
required this.label,
required this.value,
required this.color,
required this.skinTypo,
});
@override
@@ -268,9 +312,8 @@ class _StatRow extends StatelessWidget {
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(label, style: KittieTypography.bodyMedium)),
Text(value,
style: KittieTypography.labelLarge.copyWith(color: color)),
Expanded(child: Text(label, style: skinTypo.bodyMedium)),
Text(value, style: skinTypo.labelLarge.copyWith(color: color)),
],
);
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/skin_provider.dart';
import '../../core/theme/skin_type.dart';
/// Horizontal wrap of 7 skin tiles for selecting the active app theme.
///
/// Tapping a tile calls [SkinNotifier.setSkin] and persists the choice.
class SkinPicker extends ConsumerWidget {
const SkinPicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentSkin = ref.watch(skinProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
final t = ref.watch(tProvider);
return Wrap(
spacing: 8,
runSpacing: 8,
children: SkinType.values.map((skin) {
final isSelected = skin == currentSkin;
final colors = skin.colors;
return GestureDetector(
onTap: () => ref.read(skinProvider.notifier).setSkin(skin),
child: SizedBox(
width: 90,
child: Stack(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.cream,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? skinColors.brown
: Colors.grey.withValues(alpha: 0.3),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Color dots preview
Row(
children: [
_ColorDot(color: colors.brown),
const SizedBox(width: 4),
_ColorDot(color: colors.sageGreen),
const SizedBox(width: 4),
_ColorDot(color: colors.energyHigh),
],
),
const SizedBox(height: 6),
// Skin name
Text(
t(skin.nameKey),
style: skinTypo.labelSmall.copyWith(
fontWeight: FontWeight.bold,
color: skinColors.brownDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Skin description
Text(
t(skin.descriptionKey),
style: skinTypo.labelSmall.copyWith(
fontSize: 9,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Selected checkmark overlay
if (isSelected)
Positioned(
top: 4,
right: 4,
child: Icon(
Icons.check_circle,
size: 16,
color: skinColors.brown,
),
),
],
),
),
);
}).toList(),
);
}
}
class _ColorDot extends StatelessWidget {
const _ColorDot({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
);
}
}
+31 -45
View File
@@ -2,8 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
/// Day status for a single streak dot.
enum DayStatus { done, today, future }
@@ -11,9 +10,9 @@ 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)
/// - done: sageGreen
/// - today: amber
/// - future: creamBorder
///
/// Shows fire emoji + streak count above the dots.
class StreakDots extends ConsumerWidget {
@@ -65,6 +64,8 @@ class StreakDots extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Column(
mainAxisSize: MainAxisSize.min,
@@ -77,8 +78,8 @@ class StreakDots extends ConsumerWidget {
const SizedBox(width: 4),
Text(
'$_effectiveCount',
style: KittieTypography.labelLarge.copyWith(
color: KittieColors.brown,
style: skinTypo.labelLarge.copyWith(
color: skinColors.brown,
),
),
],
@@ -88,11 +89,31 @@ class StreakDots extends ConsumerWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
final color = switch (_effectiveDays[i]) {
DayStatus.done => skinColors.sageGreen,
DayStatus.today => skinColors.amber,
DayStatus.future => skinColors.creamBorder,
};
return Padding(
padding: EdgeInsets.only(left: i > 0 ? 8 : 0),
child: _DayDot(
label: t(_dayKeys[i]),
status: _effectiveDays[i],
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration:
BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(height: 4),
Text(
t(_dayKeys[i]),
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
fontSize: 10,
),
),
],
),
);
}),
@@ -101,38 +122,3 @@ class StreakDots extends ConsumerWidget {
);
}
}
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,
),
),
],
);
}
}
+49 -27
View File
@@ -4,8 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/app_constants.dart';
import '../../core/i18n/i18n_provider.dart';
import '../../core/theme/kittie_colors.dart';
import '../../core/theme/kittie_typography.dart';
import '../../core/theme/skin_provider.dart';
import '../../domain/entities/task_entity.dart';
import 'energy_badge.dart';
@@ -18,7 +17,7 @@ import 'energy_badge.dart';
/// - Purple "Self-care" tag when [isSelfCare] is true
/// - Red tint overlay when [isOverdue] is true
/// - Minimum height of 56px (Apple HIG touch target)
class TaskCard extends StatelessWidget {
class TaskCard extends ConsumerWidget {
const TaskCard({
super.key,
this.task,
@@ -53,32 +52,43 @@ class TaskCard extends StatelessWidget {
if (task?.deadline != null) return task!.deadline!.isBefore(DateTime.now());
return isOverdue;
}
VoidCallback? get _toggleCallback => onComplete ?? onToggle;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: AppConstants.minTouchTarget),
constraints:
const BoxConstraints(minHeight: AppConstants.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: _isOverdue
? KittieColors.redLight
: KittieColors.creamDark,
color: _isOverdue ? skinColors.redLight : skinColors.creamDark,
borderRadius: BorderRadius.circular(AppConstants.cardBorderRadius),
),
child: Row(
children: [
_Checkbox(isDone: _isDone, onToggle: _toggleCallback),
const SizedBox(width: 12),
Expanded(child: _Content(
title: _title,
energyLevel: _energyLevel,
_Checkbox(
isDone: _isDone,
isSelfCare: _isSelfCare,
timeText: timeText,
)),
onToggle: _toggleCallback,
skinColors: skinColors,
),
const SizedBox(width: 12),
Expanded(
child: _Content(
title: _title,
energyLevel: _energyLevel,
isDone: _isDone,
isSelfCare: _isSelfCare,
timeText: timeText,
skinColors: skinColors,
skinTypo: skinTypo,
),
),
],
),
),
@@ -87,10 +97,15 @@ class TaskCard extends StatelessWidget {
}
class _Checkbox extends StatelessWidget {
const _Checkbox({required this.isDone, this.onToggle});
const _Checkbox({
required this.isDone,
required this.skinColors,
this.onToggle,
});
final bool isDone;
final VoidCallback? onToggle;
final dynamic skinColors;
@override
Widget build(BuildContext context) {
@@ -105,9 +120,10 @@ class _Checkbox extends StatelessWidget {
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone ? KittieColors.sageGreen : Colors.transparent,
color: isDone ? skinColors.sageGreen : Colors.transparent,
border: Border.all(
color: isDone ? KittieColors.sageGreen : KittieColors.brownLight,
color:
isDone ? skinColors.sageGreen : skinColors.brownLight,
width: 2,
),
),
@@ -125,6 +141,8 @@ class _Content extends StatelessWidget {
required this.energyLevel,
required this.isDone,
required this.isSelfCare,
required this.skinColors,
required this.skinTypo,
this.timeText,
});
@@ -133,6 +151,8 @@ class _Content extends StatelessWidget {
final bool isDone;
final bool isSelfCare;
final String? timeText;
final dynamic skinColors;
final dynamic skinTypo;
@override
Widget build(BuildContext context) {
@@ -142,11 +162,11 @@ class _Content extends StatelessWidget {
children: [
AnimatedDefaultTextStyle(
duration: AppConstants.animationFast,
style: KittieTypography.bodyMedium.copyWith(
style: skinTypo.bodyMedium.copyWith(
decoration: isDone ? TextDecoration.lineThrough : null,
color: isDone
? KittieColors.brown.withValues(alpha: 0.4)
: KittieColors.brown,
? skinColors.brown.withValues(alpha: 0.4)
: skinColors.brown,
),
child: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
),
@@ -162,8 +182,8 @@ class _Content extends StatelessWidget {
const SizedBox(width: 8),
Text(
timeText!,
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.brownLight,
style: skinTypo.labelSmall.copyWith(
color: skinColors.brownLight,
),
),
],
@@ -180,19 +200,21 @@ class _SelfCareTag extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = ref.watch(tProvider);
final skinColors = ref.watch(skinColorsProvider);
final skinTypo = ref.watch(skinTypographyProvider);
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.purpleLight,
color: skinColors.purpleLight,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
t('self_care.tag'),
style: KittieTypography.labelSmall.copyWith(
color: KittieColors.purple,
style: skinTypo.labelSmall.copyWith(
color: skinColors.purple,
),
),
),
+1 -1
View File
@@ -281,7 +281,7 @@ void main() {
for (final skin in AppSkin.values) {
final theme = skin.buildTheme();
final bgColor = theme.elevatedButtonTheme.style!.backgroundColor
?.resolve({}) as Color?;
?.resolve({});
expect(bgColor, theme.colorScheme.primary,
reason: '${skin.key} button bg should match primary');
}
+40 -36
View File
@@ -1,12 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kittie_mobile/core/theme/app_skin.dart';
import 'package:kittie_mobile/core/theme/skin_provider.dart';
import 'package:kittie_mobile/core/theme/skin_type.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
setInitialSkin(SkinType.defaultSkin);
});
group('SkinNotifier — initial state', () {
@@ -14,7 +15,15 @@ void main() {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), AppSkin.defaultSkin);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('uses initialSkin set via setInitialSkin', () {
setInitialSkin(SkinType.epic);
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(skinProvider), SkinType.epic);
});
});
@@ -23,18 +32,18 @@ void main() {
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).setSkin(AppSkin.epic);
expect(container.read(skinProvider), AppSkin.epic);
await container.read(skinProvider.notifier).setSkin(SkinType.epic);
expect(container.read(skinProvider), SkinType.epic);
});
test('can switch through all 7 skins', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
for (final skin in AppSkin.values) {
for (final skin in SkinType.values) {
await container.read(skinProvider.notifier).setSkin(skin);
expect(container.read(skinProvider), skin,
reason: 'should have switched to ${skin.key}');
reason: 'should have switched to ${skin.storageKey}');
}
});
@@ -42,14 +51,14 @@ void main() {
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).setSkin(AppSkin.senior);
await container.read(skinProvider.notifier).setSkin(SkinType.senior);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('app_skin'), 'senior');
});
test('persists correct key for each skin', () async {
for (final skin in AppSkin.values) {
for (final skin in SkinType.values) {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer();
addTearDown(container.dispose);
@@ -59,72 +68,67 @@ void main() {
final prefs = await SharedPreferences.getInstance();
expect(
prefs.getString('app_skin'),
skin.key,
reason: '${skin.key} should be persisted',
skin.storageKey,
reason: '${skin.storageKey} should be persisted',
);
}
});
});
group('SkinNotifier.loadSaved', () {
test('restores persisted skin from SharedPreferences', () async {
SharedPreferences.setMockInitialValues({'app_skin': 'future'});
group('setInitialSkin — pre-runApp skin loading', () {
test('restores persisted skin via setInitialSkin', () {
setInitialSkin(SkinTypeExtension.fromStorageKey('future'));
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).loadSaved();
expect(container.read(skinProvider), AppSkin.future);
expect(container.read(skinProvider), SkinType.future);
});
test('stays at defaultSkin when no value is persisted', () async {
SharedPreferences.setMockInitialValues({});
test('stays at defaultSkin when no value is persisted', () {
setInitialSkin(SkinTypeExtension.fromStorageKey(''));
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).loadSaved();
expect(container.read(skinProvider), AppSkin.defaultSkin);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('falls back to defaultSkin for unknown persisted key', () async {
SharedPreferences.setMockInitialValues({'app_skin': 'nonexistent'});
test('falls back to defaultSkin for unknown persisted key', () {
setInitialSkin(SkinTypeExtension.fromStorageKey('nonexistent'));
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).loadSaved();
expect(container.read(skinProvider), AppSkin.defaultSkin);
expect(container.read(skinProvider), SkinType.defaultSkin);
});
test('can restore all 7 skins', () async {
for (final skin in AppSkin.values) {
SharedPreferences.setMockInitialValues({'app_skin': skin.key});
test('can restore all 7 skins', () {
for (final skin in SkinType.values) {
setInitialSkin(SkinTypeExtension.fromStorageKey(skin.storageKey));
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(skinProvider.notifier).loadSaved();
expect(
container.read(skinProvider),
skin,
reason: '${skin.key} should be restored',
reason: '${skin.storageKey} should be restored',
);
}
});
});
group('SkinNotifier — round-trip persistence', () {
test('setSkin then loadSaved restores same skin', () async {
test('setSkin then reading from prefs restores same skin', () async {
final container1 = ProviderContainer();
await container1.read(skinProvider.notifier).setSkin(AppSkin.kids);
await container1.read(skinProvider.notifier).setSkin(SkinType.kids);
container1.dispose();
final prefs = await SharedPreferences.getInstance();
final key = prefs.getString('app_skin')!;
setInitialSkin(SkinTypeExtension.fromStorageKey(key));
final container2 = ProviderContainer();
addTearDown(container2.dispose);
await container2.read(skinProvider.notifier).loadSaved();
expect(container2.read(skinProvider), AppSkin.kids);
expect(container2.read(skinProvider), SkinType.kids);
});
});
}