feat: Onboarding flow + Add Task bottom sheet

This commit is contained in:
TrochtaOndrej
2026-03-18 04:41:49 +01:00
parent 03e1ab1d24
commit 21c431c539
16 changed files with 1538 additions and 133 deletions
View File
+24
View File
@@ -0,0 +1,24 @@
import 'package:go_router/go_router.dart';
import '../../features/onboarding/screens/onboarding_screen.dart';
import '../../features/tasks/screens/tasks_screen.dart';
import 'routes.dart';
/// Creates the app-wide [GoRouter] instance.
///
/// [onboardingComplete] determines whether to start at onboarding or tasks.
GoRouter createAppRouter({required bool onboardingComplete}) {
return GoRouter(
initialLocation: onboardingComplete ? Routes.tasks : Routes.onboarding,
routes: [
GoRoute(
path: Routes.onboarding,
builder: (context, state) => const OnboardingScreen(),
),
GoRoute(
path: Routes.tasks,
builder: (context, state) => const TasksScreen(),
),
],
);
}
+6
View File
@@ -0,0 +1,6 @@
/// Route name constants for go_router.
abstract final class Routes {
static const String onboarding = '/onboarding';
static const String tasks = '/tasks';
static const String addTask = '/add-task';
}
View File
@@ -0,0 +1,43 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'onboarding_providers.g.dart';
/// Pet types available during onboarding.
enum PetType { dog, cat, capybara }
/// Current onboarding step (0-based).
@riverpod
class OnboardingStep extends _$OnboardingStep {
@override
int build() => 0;
void next() {
if (state < 2) state = state + 1;
}
void previous() {
if (state > 0) state = state - 1;
}
void goTo(int step) {
if (step >= 0 && step <= 2) state = step;
}
}
/// Selected pet type — defaults to cat.
@riverpod
class SelectedPetType extends _$SelectedPetType {
@override
PetType build() => PetType.cat;
void select(PetType type) => state = type;
}
/// Pet name — defaults to "Míša".
@riverpod
class PetName extends _$PetName {
@override
String build() => 'Míša';
void update(String name) => state = name;
}
@@ -0,0 +1,164 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'onboarding_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Current onboarding step (0-based).
@ProviderFor(OnboardingStep)
final onboardingStepProvider = OnboardingStepProvider._();
/// Current onboarding step (0-based).
final class OnboardingStepProvider
extends $NotifierProvider<OnboardingStep, int> {
/// Current onboarding step (0-based).
OnboardingStepProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'onboardingStepProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$onboardingStepHash();
@$internal
@override
OnboardingStep create() => OnboardingStep();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$onboardingStepHash() => r'b8640b7a39647f58b2b7dc428f46a994ad14ce9a';
/// Current onboarding step (0-based).
abstract class _$OnboardingStep extends $Notifier<int> {
int build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<int, int>;
final element = ref.element
as $ClassProviderElement<AnyNotifier<int, int>, int, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Selected pet type — defaults to cat.
@ProviderFor(SelectedPetType)
final selectedPetTypeProvider = SelectedPetTypeProvider._();
/// Selected pet type — defaults to cat.
final class SelectedPetTypeProvider
extends $NotifierProvider<SelectedPetType, PetType> {
/// Selected pet type — defaults to cat.
SelectedPetTypeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedPetTypeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedPetTypeHash();
@$internal
@override
SelectedPetType create() => SelectedPetType();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(PetType value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<PetType>(value),
);
}
}
String _$selectedPetTypeHash() => r'f22a57222416e1b1dba8583b76055267af78cfdb';
/// Selected pet type — defaults to cat.
abstract class _$SelectedPetType extends $Notifier<PetType> {
PetType build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<PetType, PetType>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<PetType, PetType>, PetType, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Pet name — defaults to "Míša".
@ProviderFor(PetName)
final petNameProvider = PetNameProvider._();
/// Pet name — defaults to "Míša".
final class PetNameProvider extends $NotifierProvider<PetName, String> {
/// Pet name — defaults to "Míša".
PetNameProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'petNameProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$petNameHash();
@$internal
@override
PetName create() => PetName();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$petNameHash() => r'e4f8b65d291597e897f1e0f44a8c086a4e41b4ae';
/// Pet name — defaults to "Míša".
abstract class _$PetName extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String, String>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<String, String>, String, Object?, Object?>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,360 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import '../providers/onboarding_providers.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
late final PageController _pageController;
late final TextEditingController _nameController;
@override
void initState() {
super.initState();
_pageController = PageController();
_nameController = TextEditingController(text: 'Míša');
}
@override
void dispose() {
_pageController.dispose();
_nameController.dispose();
super.dispose();
}
void _goToPage(int page) {
_pageController.animateToPage(
page,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
ref.read(onboardingStepProvider.notifier).goTo(page);
}
Future<void> _completeOnboarding() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarding_complete', true);
await prefs.setString(
'pet_type',
ref.read(selectedPetTypeProvider).name,
);
await prefs.setString('pet_name', ref.read(petNameProvider));
if (mounted) {
context.go('/tasks');
}
}
@override
Widget build(BuildContext context) {
final step = ref.watch(onboardingStepProvider);
return Scaffold(
backgroundColor: KittieColors.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),
),
);
}),
),
),
// Pages
Expanded(
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (page) {
ref.read(onboardingStepProvider.notifier).goTo(page);
},
children: [
_WelcomeStep(onNext: () => _goToPage(1)),
_PetSelectionStep(
nameController: _nameController,
onNext: () => _goToPage(2),
onBack: () => _goToPage(0),
),
_ReadyStep(
onComplete: _completeOnboarding,
onBack: () => _goToPage(1),
),
],
),
),
],
),
),
);
}
}
// ─── Step 1: Welcome ────────────────────────────────────────────────────────
class _WelcomeStep extends StatelessWidget {
const _WelcomeStep({required this.onNext});
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🐱', style: TextStyle(fontSize: 80)),
const SizedBox(height: 24),
Text(
'Vítej v KittieMobile',
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Tvůj mazlíček ti pomůže zvládnout úkoly.',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
),
child: const Text('Začít'),
),
),
],
),
);
}
}
// ─── Step 2: Pet Selection ──────────────────────────────────────────────────
class _PetSelectionStep extends ConsumerWidget {
const _PetSelectionStep({
required this.nameController,
required this.onNext,
required this.onBack,
});
final TextEditingController nameController;
final VoidCallback onNext;
final VoidCallback onBack;
static const _pets = [
(type: PetType.dog, emoji: '🐶', label: 'Pes'),
(type: PetType.cat, emoji: '🐱', label: 'Kočka'),
(type: PetType.capybara, emoji: '🦫', label: 'Kapybara'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedPetTypeProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Vyber si mazlíčka',
style: KittieTypography.h2.copyWith(color: KittieColors.cream),
),
const SizedBox(height: 32),
// Pet grid
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _pets.map((pet) {
final isSelected = selected == pet.type;
return GestureDetector(
onTap: () {
ref.read(selectedPetTypeProvider.notifier).select(pet.type);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 80,
height: 80,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: KittieColors.brownMid,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSelected ? KittieColors.amber : Colors.transparent,
width: 2,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(pet.emoji, style: const TextStyle(fontSize: 32)),
const SizedBox(height: 4),
Text(
pet.label,
style: KittieTypography.labelSmall
.copyWith(color: KittieColors.cream),
),
],
),
),
);
}).toList(),
),
const SizedBox(height: 32),
// Name input
Text(
'Jak se bude jmenovat?',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.cream),
),
const SizedBox(height: 12),
TextField(
controller: nameController,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
textAlign: TextAlign.center,
decoration: InputDecoration(
fillColor: KittieColors.creamDark,
hintText: 'Míša',
hintStyle: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownLight),
),
onChanged: (value) {
ref.read(petNameProvider.notifier).update(value);
},
),
const SizedBox(height: 32),
// Navigation buttons
Row(
children: [
TextButton(
onPressed: onBack,
child: Text(
'Zpět',
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
),
),
const Spacer(),
ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.amber,
foregroundColor: KittieColors.brownDark,
),
child: const Text('Pokračovat'),
),
],
),
],
),
);
}
}
// ─── Step 3: Ready ──────────────────────────────────────────────────────────
class _ReadyStep extends ConsumerWidget {
const _ReadyStep({required this.onComplete, required this.onBack});
final VoidCallback onComplete;
final VoidCallback onBack;
@override
Widget build(BuildContext context, WidgetRef ref) {
final petName = ref.watch(petNameProvider);
final petType = ref.watch(selectedPetTypeProvider);
final emoji = switch (petType) {
PetType.dog => '🐶',
PetType.cat => '🐱',
PetType.capybara => '🦫',
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
size: 80,
),
const SizedBox(height: 24),
Text(
'Vše je připraveno!',
style: KittieTypography.h1.copyWith(color: KittieColors.cream),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'$emoji $petName je připraven/a ti pomáhat.',
style:
KittieTypography.bodyLarge.copyWith(color: KittieColors.amber),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
Row(
children: [
TextButton(
onPressed: onBack,
child: Text(
'Zpět',
style: KittieTypography.labelLarge
.copyWith(color: KittieColors.brownLight),
),
),
const Spacer(),
ElevatedButton(
onPressed: onComplete,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
),
child: const Text('Začít používat'),
),
],
),
],
),
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'add_task_providers.g.dart';
/// Current add-task step (0-based, 4 steps total).
@riverpod
class AddTaskStep extends _$AddTaskStep {
@override
int build() => 0;
void next() {
if (state < 3) state = state + 1;
}
void previous() {
if (state > 0) state = state - 1;
}
void reset() => state = 0;
}
/// Task title.
@riverpod
class TaskTitle extends _$TaskTitle {
@override
String build() => '';
void update(String value) => state = value;
}
/// Task note (optional).
@riverpod
class TaskNote extends _$TaskNote {
@override
String build() => '';
void update(String value) => state = value;
}
/// Selected day for the task.
enum TaskDay { today, tomorrow, other, someday }
@riverpod
class SelectedTaskDay extends _$SelectedTaskDay {
@override
TaskDay? build() => null;
void select(TaskDay day) => state = day;
}
/// Selected time preset.
@riverpod
class SelectedTaskTime extends _$SelectedTaskTime {
@override
TimeOfDay? build() => null;
void select(TimeOfDay time) => state = time;
}
/// Selected energy level (1-3).
@riverpod
class SelectedEnergy extends _$SelectedEnergy {
@override
int? build() => null;
void select(int level) => state = level;
}
@@ -0,0 +1,312 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'add_task_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Current add-task step (0-based, 4 steps total).
@ProviderFor(AddTaskStep)
final addTaskStepProvider = AddTaskStepProvider._();
/// Current add-task step (0-based, 4 steps total).
final class AddTaskStepProvider extends $NotifierProvider<AddTaskStep, int> {
/// Current add-task step (0-based, 4 steps total).
AddTaskStepProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'addTaskStepProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$addTaskStepHash();
@$internal
@override
AddTaskStep create() => AddTaskStep();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$addTaskStepHash() => r'631dbac166b380b4703a8397bfb53929c3219414';
/// Current add-task step (0-based, 4 steps total).
abstract class _$AddTaskStep extends $Notifier<int> {
int build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<int, int>;
final element = ref.element
as $ClassProviderElement<AnyNotifier<int, int>, int, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Task title.
@ProviderFor(TaskTitle)
final taskTitleProvider = TaskTitleProvider._();
/// Task title.
final class TaskTitleProvider extends $NotifierProvider<TaskTitle, String> {
/// Task title.
TaskTitleProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'taskTitleProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$taskTitleHash();
@$internal
@override
TaskTitle create() => TaskTitle();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$taskTitleHash() => r'b13b965832d8a85b395589a181b35b0e25d5fe28';
/// Task title.
abstract class _$TaskTitle extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String, String>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<String, String>, String, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Task note (optional).
@ProviderFor(TaskNote)
final taskNoteProvider = TaskNoteProvider._();
/// Task note (optional).
final class TaskNoteProvider extends $NotifierProvider<TaskNote, String> {
/// Task note (optional).
TaskNoteProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'taskNoteProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$taskNoteHash();
@$internal
@override
TaskNote create() => TaskNote();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String>(value),
);
}
}
String _$taskNoteHash() => r'f2bc74473d53d259289317273b0ebebc501feab1';
/// Task note (optional).
abstract class _$TaskNote extends $Notifier<String> {
String build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String, String>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<String, String>, String, Object?, Object?>;
element.handleCreate(ref, build);
}
}
@ProviderFor(SelectedTaskDay)
final selectedTaskDayProvider = SelectedTaskDayProvider._();
final class SelectedTaskDayProvider
extends $NotifierProvider<SelectedTaskDay, TaskDay?> {
SelectedTaskDayProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTaskDayProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTaskDayHash();
@$internal
@override
SelectedTaskDay create() => SelectedTaskDay();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TaskDay? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TaskDay?>(value),
);
}
}
String _$selectedTaskDayHash() => r'dae3cf690e0b896deaf52a1dc59780962134fe6e';
abstract class _$SelectedTaskDay extends $Notifier<TaskDay?> {
TaskDay? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TaskDay?, TaskDay?>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<TaskDay?, TaskDay?>, TaskDay?, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Selected time preset.
@ProviderFor(SelectedTaskTime)
final selectedTaskTimeProvider = SelectedTaskTimeProvider._();
/// Selected time preset.
final class SelectedTaskTimeProvider
extends $NotifierProvider<SelectedTaskTime, TimeOfDay?> {
/// Selected time preset.
SelectedTaskTimeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedTaskTimeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedTaskTimeHash();
@$internal
@override
SelectedTaskTime create() => SelectedTaskTime();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TimeOfDay? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TimeOfDay?>(value),
);
}
}
String _$selectedTaskTimeHash() => r'287e12b497319c2c9f0d394a73816953de87950d';
/// Selected time preset.
abstract class _$SelectedTaskTime extends $Notifier<TimeOfDay?> {
TimeOfDay? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<TimeOfDay?, TimeOfDay?>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<TimeOfDay?, TimeOfDay?>, TimeOfDay?, Object?, Object?>;
element.handleCreate(ref, build);
}
}
/// Selected energy level (1-3).
@ProviderFor(SelectedEnergy)
final selectedEnergyProvider = SelectedEnergyProvider._();
/// Selected energy level (1-3).
final class SelectedEnergyProvider
extends $NotifierProvider<SelectedEnergy, int?> {
/// Selected energy level (1-3).
SelectedEnergyProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedEnergyProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedEnergyHash();
@$internal
@override
SelectedEnergy create() => SelectedEnergy();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int?>(value),
);
}
}
String _$selectedEnergyHash() => r'ef26bcd779e3f2073c3fea5c8abc4a32d17055cf';
/// Selected energy level (1-3).
abstract class _$SelectedEnergy extends $Notifier<int?> {
int? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<int?, int?>;
final element = ref.element as $ClassProviderElement<
AnyNotifier<int?, int?>, int?, Object?, Object?>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,495 @@
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 '../providers/add_task_providers.dart';
/// Shows the Add Task bottom sheet. Call from any screen:
/// ```dart
/// showAddTaskSheet(context);
/// ```
void showAddTaskSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const _AddTaskSheet(),
);
}
class _AddTaskSheet extends ConsumerStatefulWidget {
const _AddTaskSheet();
@override
ConsumerState<_AddTaskSheet> createState() => _AddTaskSheetState();
}
class _AddTaskSheetState extends ConsumerState<_AddTaskSheet> {
late final TextEditingController _titleController;
late final TextEditingController _noteController;
@override
void initState() {
super.initState();
_titleController = TextEditingController();
_noteController = TextEditingController();
}
@override
void dispose() {
_titleController.dispose();
_noteController.dispose();
super.dispose();
}
void _saveTask() {
// TODO: persist task via repository
ref.read(addTaskStepProvider.notifier).next();
}
void _resetAndClose() {
ref.invalidate(addTaskStepProvider);
ref.invalidate(taskTitleProvider);
ref.invalidate(taskNoteProvider);
ref.invalidate(selectedTaskDayProvider);
ref.invalidate(selectedTaskTimeProvider);
ref.invalidate(selectedEnergyProvider);
_titleController.clear();
_noteController.clear();
Navigator.of(context).pop();
}
void _addAnother() {
ref.invalidate(addTaskStepProvider);
ref.invalidate(taskTitleProvider);
ref.invalidate(taskNoteProvider);
ref.invalidate(selectedTaskDayProvider);
ref.invalidate(selectedTaskTimeProvider);
ref.invalidate(selectedEnergyProvider);
_titleController.clear();
_noteController.clear();
}
@override
Widget build(BuildContext context) {
final step = ref.watch(addTaskStepProvider);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Container(
decoration: const BoxDecoration(
color: KittieColors.cream,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppConstants.bottomSheetBorderRadius),
),
),
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 16,
bottom: 24 + bottomInset,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: KittieColors.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),
),
);
}),
),
const SizedBox(height: 20),
// Step content
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: switch (step) {
0 => _Step1Title(
key: const ValueKey(0),
titleController: _titleController,
noteController: _noteController,
onNext: () {
if (_titleController.text.trim().isNotEmpty) {
ref
.read(taskTitleProvider.notifier)
.update(_titleController.text.trim());
ref
.read(taskNoteProvider.notifier)
.update(_noteController.text.trim());
ref.read(addTaskStepProvider.notifier).next();
}
},
),
1 => _Step2When(
key: const ValueKey(1),
onNext: () =>
ref.read(addTaskStepProvider.notifier).next(),
onBack: () =>
ref.read(addTaskStepProvider.notifier).previous(),
),
2 => _Step3Energy(
key: const ValueKey(2),
onSave: _saveTask,
onBack: () =>
ref.read(addTaskStepProvider.notifier).previous(),
),
3 => _Step4Confirmation(
key: const ValueKey(3),
onClose: _resetAndClose,
onAddAnother: _addAnother,
),
_ => const SizedBox.shrink(),
},
),
],
),
);
}
}
// ─── Step 1: Title & Note ───────────────────────────────────────────────────
class _Step1Title extends StatelessWidget {
const _Step1Title({
super.key,
required this.titleController,
required this.noteController,
required this.onNext,
});
final TextEditingController titleController;
final TextEditingController noteController;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Nový úkol', style: KittieTypography.h2),
const SizedBox(height: 20),
Text('NÁZEV', style: KittieTypography.labelMedium),
const SizedBox(height: 8),
TextField(
controller: titleController,
autofocus: true,
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownDark),
decoration: const InputDecoration(hintText: 'Co potřebuješ udělat?'),
),
const SizedBox(height: 16),
Text('POZNÁMKA', style: KittieTypography.labelMedium),
const SizedBox(height: 8),
TextField(
controller: noteController,
style: KittieTypography.bodyMedium
.copyWith(color: KittieColors.brownDark),
maxLines: 3,
decoration: const InputDecoration(hintText: 'Volitelné...'),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onNext,
child: const Text('Pokračovat'),
),
),
],
);
}
}
// ─── Step 2: When ───────────────────────────────────────────────────────────
class _Step2When extends ConsumerWidget {
const _Step2When({
super.key,
required this.onNext,
required this.onBack,
});
final VoidCallback onNext;
final VoidCallback onBack;
static const _days = [
(day: TaskDay.today, label: 'Dnes'),
(day: TaskDay.tomorrow, label: 'Zítra'),
(day: TaskDay.other, label: 'Jindy'),
(day: TaskDay.someday, label: 'Někdy'),
];
static const _timePresets = [8, 10, 14, 18];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedDay = ref.watch(selectedTaskDayProvider);
final selectedTime = ref.watch(selectedTaskTimeProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Kdy to chceš udělat?', style: KittieTypography.h2),
const SizedBox(height: 20),
// Day chips
Wrap(
spacing: 8,
runSpacing: 8,
children: _days.map((d) {
final isSelected = selectedDay == d.day;
return ChoiceChip(
label: Text(d.label),
selected: isSelected,
onSelected: (_) {
ref.read(selectedTaskDayProvider.notifier).select(d.day);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide.none,
showCheckmark: false,
);
}).toList(),
),
const SizedBox(height: 24),
// Time presets
Text('Čas', style: KittieTypography.labelMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _timePresets.map((hour) {
final time = TimeOfDay(hour: hour, minute: 0);
final isSelected = selectedTime?.hour == hour;
return ChoiceChip(
label: Text('$hour:00'),
selected: isSelected,
onSelected: (_) {
ref.read(selectedTaskTimeProvider.notifier).select(time);
},
selectedColor: KittieColors.amber,
backgroundColor: KittieColors.creamDark,
labelStyle: KittieTypography.labelLarge.copyWith(
color:
isSelected ? KittieColors.brownDark : KittieColors.brown,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide.none,
showCheckmark: false,
);
}).toList(),
),
const SizedBox(height: 24),
Row(
children: [
TextButton(
onPressed: onBack,
child: const Text('Zpět'),
),
const Spacer(),
ElevatedButton(
onPressed: onNext,
child: const Text('Pokračovat'),
),
],
),
],
);
}
}
// ─── Step 3: Energy ─────────────────────────────────────────────────────────
class _Step3Energy extends ConsumerWidget {
const _Step3Energy({
super.key,
required this.onSave,
required this.onBack,
});
final VoidCallback onSave;
final VoidCallback onBack;
static const _energyOptions = [
(level: 1, label: 'Lehké', emoji: '🍃', color: KittieColors.sageGreen),
(level: 2, label: 'Střední', emoji: '', color: KittieColors.orange),
(level: 3, label: 'Náročné', emoji: '🔥', color: KittieColors.red),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedEnergyProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pet bubble
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: KittieColors.creamDark,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Text('🐱', style: TextStyle(fontSize: 28)),
const SizedBox(width: 12),
Expanded(
child: Text(
'Kolik energie to bude stát?',
style: KittieTypography.bodyLarge,
),
),
],
),
),
const SizedBox(height: 24),
// Energy picker
Row(
children: _energyOptions.map((opt) {
final isSelected = selected == opt.level;
return Expanded(
child: GestureDetector(
onTap: () {
ref.read(selectedEnergyProvider.notifier).select(opt.level);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: isSelected
? opt.color.withValues(alpha: 0.15)
: KittieColors.creamDark,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? opt.color : Colors.transparent,
width: 2,
),
),
child: Column(
children: [
Text(opt.emoji, style: const TextStyle(fontSize: 28)),
const SizedBox(height: 8),
Text(
opt.label,
style: KittieTypography.labelLarge.copyWith(
color: isSelected ? opt.color : KittieColors.brown,
),
),
],
),
),
),
);
}).toList(),
),
const SizedBox(height: 24),
Row(
children: [
TextButton(
onPressed: onBack,
child: const Text('Zpět'),
),
const Spacer(),
ElevatedButton(
onPressed: selected != null ? onSave : null,
style: ElevatedButton.styleFrom(
backgroundColor: KittieColors.sageGreen,
foregroundColor: KittieColors.cream,
disabledBackgroundColor: KittieColors.creamDark,
),
child: const Text('Uložit úkol'),
),
],
),
],
);
}
}
// ─── Step 4: Confirmation ───────────────────────────────────────────────────
class _Step4Confirmation extends StatelessWidget {
const _Step4Confirmation({
super.key,
required this.onClose,
required this.onAddAnother,
});
final VoidCallback onClose;
final VoidCallback onAddAnother;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Icon(
Icons.check_circle_rounded,
color: KittieColors.sageGreen,
size: 64,
),
const SizedBox(height: 16),
Text('Úkol uložen!', style: KittieTypography.h2),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onClose,
child: const Text('Zpět na seznam'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: onAddAnother,
child: const Text('Přidat další úkol'),
),
),
],
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../../../core/theme/kittie_colors.dart';
import '../../../core/theme/kittie_typography.dart';
import 'add_task_screen.dart';
/// Placeholder tasks list screen.
class TasksScreen extends StatelessWidget {
const TasksScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Úkoly'),
),
body: Center(
child: Text(
'Zatím žádné úkoly',
style: KittieTypography.bodyLarge
.copyWith(color: KittieColors.brownLight),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => showAddTaskSheet(context),
child: const Icon(Icons.add),
),
);
}
}
+26 -111
View File
@@ -1,122 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
import 'core/router/app_router.dart';
import 'core/theme/kittie_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final onboardingComplete = prefs.getBool('onboarding_complete') ?? false;
runApp(
ProviderScope(
child: KittieApp(onboardingComplete: onboardingComplete),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
class KittieApp extends StatelessWidget {
const KittieApp({super.key, required this.onboardingComplete});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
final bool onboardingComplete;
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
final router = createAppRouter(onboardingComplete: onboardingComplete);
return MaterialApp.router(
title: 'KittieMobile',
debugShowCheckedModeBanner: false,
theme: KittieTheme.light,
routerConfig: router,
);
}
}
+10 -22
View File
@@ -1,30 +1,18 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kittie_mobile/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('KittieApp renders', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(
child: KittieApp(onboardingComplete: false),
),
);
await tester.pumpAndSettle();
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
// Onboarding welcome screen should be visible
expect(find.text('Vítej v KittieMobile'), findsOneWidget);
});
}