597 lines
19 KiB
Dart
597 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../theme/app_theme.dart';
|
|
import '../models/app_settings.dart';
|
|
import '../models/focus_session.dart';
|
|
import '../models/history_repository.dart';
|
|
import '../widgets/ambient_mesh_background.dart';
|
|
import '../widgets/rotary_knob.dart';
|
|
import '../widgets/bottom_sheets.dart';
|
|
import 'breathe_page.dart';
|
|
import 'stats_page.dart';
|
|
import 'settings_page.dart';
|
|
|
|
class HomePage extends StatefulWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
State<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|
double _setDuration = 25.0;
|
|
double _currentDuration = 0;
|
|
bool _isTiming = false;
|
|
Timer? _timer;
|
|
|
|
final List<String> _tags = ["Deep Work", "Reading", "Meditation", "Creativity"];
|
|
int _selectedTagIndex = 0;
|
|
String? _currentIntent;
|
|
|
|
late AnimationController _breathingController;
|
|
late AnimationController _scaleController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_currentDuration = _setDuration * 60;
|
|
|
|
_breathingController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 4),
|
|
)..repeat(reverse: true);
|
|
|
|
_scaleController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 300),
|
|
lowerBound: 0.95,
|
|
upperBound: 1.0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
_breathingController.dispose();
|
|
_scaleController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _triggerHaptic() {
|
|
if (AppSettings.enableHaptics) {
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
}
|
|
|
|
void _handleStartRequest() {
|
|
if (_isTiming) {
|
|
_stopTimer();
|
|
} else {
|
|
_showIntentDialog();
|
|
}
|
|
}
|
|
|
|
void _showIntentDialog() {
|
|
final TextEditingController intentController = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
backgroundColor: Colors.white.withOpacity(0.95),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
title: const Text(
|
|
"Focus Intent",
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
"What is your goal?",
|
|
style: TextStyle(color: AppTheme.textSub, fontSize: 16),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: intentController,
|
|
autofocus: true,
|
|
style: const TextStyle(
|
|
color: AppTheme.textMain,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: "e.g. Design System",
|
|
hintStyle: TextStyle(color: Colors.grey[400]),
|
|
filled: true,
|
|
fillColor: AppTheme.bgLight,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_startTimer(intent: null);
|
|
},
|
|
child: const Text(
|
|
"Skip",
|
|
style: TextStyle(color: AppTheme.textSub),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_startTimer(intent: intentController.text.trim());
|
|
},
|
|
child: const Text(
|
|
"START",
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w900,
|
|
color: AppTheme.primary,
|
|
letterSpacing: 1,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _startTimer({String? intent}) {
|
|
if (AppSettings.enableHaptics) HapticFeedback.heavyImpact();
|
|
setState(() {
|
|
_isTiming = true;
|
|
_currentIntent = (intent != null && intent.isNotEmpty) ? intent : null;
|
|
_currentDuration = _setDuration * 60;
|
|
});
|
|
_scaleController.forward();
|
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
if (_currentDuration > 0) {
|
|
setState(() => _currentDuration--);
|
|
} else {
|
|
_stopTimer(completed: true);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopTimer({bool completed = false}) {
|
|
_timer?.cancel();
|
|
_scaleController.reverse();
|
|
setState(() => _isTiming = false);
|
|
|
|
if (completed) {
|
|
_saveSession(5.0);
|
|
_showCompletionDialog();
|
|
} else {
|
|
_showRatingSheet();
|
|
}
|
|
}
|
|
|
|
void _saveSession(double rating) {
|
|
HistoryRepository.addSession(FocusSession(
|
|
startTime: DateTime.now(),
|
|
durationMinutes: (_setDuration - _currentDuration / 60).round(),
|
|
tag: _tags[_selectedTagIndex],
|
|
intent: _currentIntent,
|
|
rating: rating,
|
|
));
|
|
}
|
|
|
|
void _showRatingSheet() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => FocusRatingSheet(
|
|
onSave: (rating) {
|
|
_saveSession(rating);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCompletionDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: Colors.white.withOpacity(0.95),
|
|
title: const Text(
|
|
"Session Complete",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontWeight: FontWeight.w800),
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.check_circle_outline,
|
|
size: 80,
|
|
color: AppTheme.accent,
|
|
),
|
|
if (_currentIntent != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'"${_currentIntent!}"',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 18,
|
|
color: AppTheme.textMain,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
"completed!",
|
|
style: TextStyle(color: AppTheme.textSub),
|
|
),
|
|
]
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text(
|
|
"Done",
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.of(context).size;
|
|
final isSmallScreen = size.height < 700;
|
|
|
|
return Scaffold(
|
|
extendBodyBehindAppBar: true,
|
|
body: Stack(
|
|
children: [
|
|
AmbientMeshBackground(isDark: _isTiming),
|
|
SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildAppBar(),
|
|
const Spacer(),
|
|
if (_isTiming && _currentIntent != null)
|
|
AnimatedOpacity(
|
|
opacity: 1.0,
|
|
duration: const Duration(milliseconds: 500),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 30),
|
|
child: Column(
|
|
children: [
|
|
const Icon(
|
|
Icons.center_focus_strong,
|
|
color: AppTheme.accent,
|
|
size: 28,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_currentIntent!,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.5,
|
|
height: 1.2,
|
|
shadows: [
|
|
Shadow(blurRadius: 15, color: Colors.black)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else
|
|
AnimatedOpacity(
|
|
opacity: _isTiming ? 0.0 : 1.0,
|
|
duration: const Duration(milliseconds: 500),
|
|
child: SizedBox(
|
|
height: 50,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 30),
|
|
itemCount: _tags.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
|
itemBuilder: (context, index) {
|
|
final isSelected = _selectedTagIndex == index;
|
|
return GestureDetector(
|
|
onTap: () {
|
|
_triggerHaptic();
|
|
setState(() => _selectedTagIndex = index);
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? AppTheme.primary
|
|
: Colors.white.withOpacity(0.6),
|
|
borderRadius: BorderRadius.circular(30),
|
|
boxShadow: isSelected ? AppTheme.softShadow : [],
|
|
),
|
|
child: Text(
|
|
_tags[index],
|
|
style: TextStyle(
|
|
color: isSelected
|
|
? Colors.white
|
|
: AppTheme.textMain,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_buildKnobSection(isSmallScreen),
|
|
if (AppSettings.strictMode && !_isTiming)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 30),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: const [
|
|
Icon(
|
|
Icons.screen_rotation,
|
|
size: 20,
|
|
color: AppTheme.textSub,
|
|
),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
"Flip phone to start",
|
|
style: TextStyle(
|
|
color: AppTheme.textSub,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
_buildBottomControls(),
|
|
const SizedBox(height: 40),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.air,
|
|
color: _isTiming ? Colors.white70 : AppTheme.primary,
|
|
size: 28,
|
|
),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(builder: (_) => const BreathePage()),
|
|
),
|
|
),
|
|
Text(
|
|
_isTiming ? "F O C U S" : "T E M P O",
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: 5,
|
|
color: _isTiming ? Colors.white : AppTheme.primary,
|
|
shadows: _isTiming
|
|
? [const Shadow(blurRadius: 5, color: Colors.black)]
|
|
: null,
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.bar_chart,
|
|
color: _isTiming ? Colors.white70 : AppTheme.primary,
|
|
size: 28,
|
|
),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(builder: (_) => const StatsPage()),
|
|
).then((_) => setState(() {})),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.settings_outlined,
|
|
color: _isTiming ? Colors.white70 : AppTheme.primary,
|
|
size: 28,
|
|
),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
CupertinoPageRoute(builder: (_) => const SettingsPage()),
|
|
).then((_) => setState(() {})),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildKnobSection(bool isSmallScreen) {
|
|
final double size = isSmallScreen ? 240 : 300;
|
|
final double knobSize = isSmallScreen ? 200 : 260;
|
|
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: _breathingController,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
(_isTiming ? AppTheme.flowStart : AppTheme.accent)
|
|
.withOpacity(0.2 * _breathingController.value),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
RotaryKnob(
|
|
size: knobSize,
|
|
value: _setDuration,
|
|
min: 1,
|
|
max: 120,
|
|
isTiming: _isTiming,
|
|
currentSeconds: _currentDuration,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
_setDuration = val;
|
|
_currentDuration = val * 60;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomControls() {
|
|
final double progress =
|
|
(HistoryRepository.todayMinutes / AppSettings.dailyGoalMinutes)
|
|
.clamp(0.0, 1.0);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
|
child: Column(
|
|
children: [
|
|
AnimatedOpacity(
|
|
opacity: _isTiming ? 0.0 : 1.0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => const SoundMixerSheet(),
|
|
);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.8),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(color: Colors.white),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
)
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: const [
|
|
Icon(Icons.graphic_eq, size: 18, color: AppTheme.primary),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
"Soundscape Mixer",
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w800,
|
|
color: AppTheme.textMain,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 30),
|
|
Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
if (!_isTiming)
|
|
SizedBox(
|
|
width: 92,
|
|
height: 92,
|
|
child: CircularProgressIndicator(
|
|
value: progress,
|
|
strokeWidth: 4,
|
|
backgroundColor: Colors.grey.withOpacity(0.2),
|
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
|
AppTheme.accent,
|
|
),
|
|
strokeCap: StrokeCap.round,
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: _handleStartRequest,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 400),
|
|
curve: Curves.easeOutCubic,
|
|
height: 80,
|
|
width: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _isTiming ? Colors.red[400] : AppTheme.primary,
|
|
boxShadow: _isTiming
|
|
? [
|
|
BoxShadow(
|
|
color: Colors.red.withOpacity(0.4),
|
|
blurRadius: 20,
|
|
spreadRadius: 5,
|
|
)
|
|
]
|
|
: AppTheme.softShadow,
|
|
),
|
|
child: Icon(
|
|
_isTiming ? Icons.stop_rounded : Icons.play_arrow_rounded,
|
|
color: Colors.white,
|
|
size: 44,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|