Files
Tomas Dvorak 37ffb93923 feat: Complete Phase 1 - Full Flutter app implementation with comprehensive features
Version: 1.1.0

Major changes:
- Implemented complete Flutter app structure with all core features
- Added comprehensive UI screens for auth, countdown, goals, profile, settings, and social features
- Integrated Supabase backend with authentication and data repositories
- Added offline support with Hive caching and local storage
- Implemented comprehensive routing with go_router
- Added location services with Google Maps integration
- Implemented notifications and home widget support
- Added voice recording capabilities and AI chat features
- Created comprehensive test suite and documentation
- Added Android and iOS platform configurations
- Implemented achievements system and social features
- Added calendar integration and bucket list functionality

This represents a complete Phase 1 milestone with 3,775 additions across 31 files.
2026-01-04 14:33:54 +01:00

907 lines
34 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
import '../../../bootstrap/env.dart';
import '../../../core/widgets/app_scaffold.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../core/utils/validators.dart';
import '../../../data/models/goal_model.dart';
import '../../../data/models/goal_step_model.dart';
import '../../../data/providers/image_search_provider.dart';
import '../../../data/providers/pexels_image_search_provider.dart';
import '../../../data/services/image_search_service.dart';
import '../../../data/services/pexels_image_search_service.dart';
import '../application/goals_controller.dart';
import 'location_picker_screen.dart';
enum OnlineImageSource { unsplash, pexels }
class LocationData {
final double latitude;
final double longitude;
final String name;
LocationData({
required this.latitude,
required this.longitude,
required this.name,
});
}
class GoalEditScreen extends ConsumerStatefulWidget {
final String? goalId;
const GoalEditScreen({super.key, this.goalId});
@override
ConsumerState<GoalEditScreen> createState() => _GoalEditScreenState();
}
class _GoalEditScreenState extends ConsumerState<GoalEditScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _stepController = TextEditingController();
int _progress = 0;
bool _isLoading = false;
final List<GoalStep> _steps = [];
final Uuid _uuid = const Uuid();
LocationData? _selectedLocation;
bool _isGettingLocation = false;
String? _selectedImagePath;
final ImagePicker _imagePicker = ImagePicker();
List<UnsplashImage> _unsplashResults = [];
List<PexelsImage> _pexelsResults = [];
bool _isSearchingImages = false;
late OnlineImageSource _selectedImageSource;
final TextEditingController _imageSearchController = TextEditingController();
@override
void initState() {
super.initState();
if (Env.unsplashEnabled) {
_selectedImageSource = OnlineImageSource.unsplash;
} else if (Env.pexelsEnabled) {
_selectedImageSource = OnlineImageSource.pexels;
} else {
_selectedImageSource = OnlineImageSource.unsplash;
}
if (widget.goalId != null) {
_loadGoal();
}
}
void _loadGoal() {
final goalsState = ref.read(goalsControllerProvider);
if (goalsState.goals.isNotEmpty) {
final goal = goalsState.goals.firstWhere((g) => g.id == widget.goalId);
_titleController.text = goal.title;
_descriptionController.text = goal.description ?? '';
_progress = goal.progress;
if (goal.hasLocation) {
_selectedLocation = LocationData(
latitude: goal.locationLat!,
longitude: goal.locationLng!,
name: goal.locationName ?? 'Selected Location',
);
}
if (goal.hasImage) {
_selectedImagePath = goal.imageUrl;
}
}
}
Future<void> _pickImage(ImageSource source) async {
try {
final XFile? image = await _imagePicker.pickImage(
source: source,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error picking image: $e')),
);
}
}
}
void _showImagePickerDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Image'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Take Photo'),
onTap: () {
Navigator.pop(context);
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Choose from Gallery'),
onTap: () {
Navigator.pop(context);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search Online'),
enabled: Env.unsplashEnabled || Env.pexelsEnabled,
onTap: (Env.unsplashEnabled || Env.pexelsEnabled)
? () {
Navigator.pop(context);
_showImageSearchDialog();
}
: null,
),
],
),
),
);
}
void _showImageSearchDialog() {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => Dialog(
child: Container(
constraints: const BoxConstraints(maxHeight: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _imageSearchController,
decoration: const InputDecoration(
hintText: 'Search for images...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onSubmitted: (query) {
_searchImages(query);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
_searchImages(_imageSearchController.text);
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Builder(
builder: (context) {
final segments = <ButtonSegment<OnlineImageSource>>[];
if (Env.unsplashEnabled) {
segments.add(const ButtonSegment(
value: OnlineImageSource.unsplash,
label: Text('Unsplash'),
icon: Icon(Icons.photo_library),
));
}
if (Env.pexelsEnabled) {
segments.add(const ButtonSegment(
value: OnlineImageSource.pexels,
label: Text('Pexels'),
icon: Icon(Icons.collections),
));
}
if (segments.isEmpty) {
return const SizedBox.shrink();
}
if (!Env.unsplashEnabled && _selectedImageSource == OnlineImageSource.unsplash && Env.pexelsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_selectedImageSource = OnlineImageSource.pexels;
});
});
}
if (!Env.pexelsEnabled && _selectedImageSource == OnlineImageSource.pexels && Env.unsplashEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_selectedImageSource = OnlineImageSource.unsplash;
});
});
}
return SegmentedButton<OnlineImageSource>(
segments: segments,
selected: {_selectedImageSource},
onSelectionChanged: (Set<OnlineImageSource> newSelection) {
setState(() => _selectedImageSource = newSelection.first);
if (_imageSearchController.text.isNotEmpty) {
_searchImages(_imageSearchController.text);
}
},
);
},
),
),
const Divider(),
Expanded(
child: _isSearchingImages
? const Center(child: CircularProgressIndicator())
: (_unsplashResults.isEmpty && _pexelsResults.isEmpty)
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Search for images using keywords from your goal title',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _selectedImageSource == OnlineImageSource.unsplash
? _unsplashResults.length
: _pexelsResults.length,
itemBuilder: (context, index) {
if (_selectedImageSource == OnlineImageSource.unsplash) {
final image = _unsplashResults[index];
return GestureDetector(
onTap: () => _selectUnsplashImage(image),
child: Card(
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
image.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image),
);
},
),
if (image.photographer != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
),
),
child: Text(
'Photo by ${image.photographer}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
} else {
final image = _pexelsResults[index];
return GestureDetector(
onTap: () => _selectPexelsImage(image),
child: Card(
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
image.url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image),
);
},
),
if (image.photographer != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.7),
],
),
),
child: Text(
'Photo by ${image.photographer}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
}
},
),
),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
setState(() {
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
Navigator.pop(context);
},
child: const Text('Cancel'),
),
],
),
),
],
),
),
),
),
);
}
void _clearImage() {
setState(() => _selectedImagePath = null);
}
Future<void> _searchImages(String query) async {
if (query.trim().isEmpty) return;
setState(() {
_isSearchingImages = true;
_unsplashResults.clear();
_pexelsResults.clear();
});
try {
if (_selectedImageSource == OnlineImageSource.unsplash) {
final imageSearchService = ref.read(imageSearchServiceProvider);
final results = await imageSearchService.searchImages(
query: query,
perPage: 10,
orientation: 'landscape',
);
setState(() => _unsplashResults = results);
} else {
final pexelsService = ref.read(pexelsImageSearchServiceProvider);
final results = await pexelsService.searchImages(
query: query,
perPage: 10,
orientation: 'landscape',
);
setState(() => _pexelsResults = results);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error searching images: $e')),
);
}
} finally {
setState(() => _isSearchingImages = false);
}
}
void _selectUnsplashImage(UnsplashImage image) {
setState(() {
_selectedImagePath = image.url;
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
if (mounted) {
Navigator.pop(context);
}
}
void _selectPexelsImage(PexelsImage image) {
setState(() {
_selectedImagePath = image.url;
_unsplashResults.clear();
_pexelsResults.clear();
_imageSearchController.clear();
});
if (mounted) {
Navigator.pop(context);
}
}
Future<void> _getCurrentLocation() async {
setState(() => _isGettingLocation = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location services are disabled')),
);
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location permissions are denied')),
);
}
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location permissions are permanently denied')),
);
}
return;
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
_selectedLocation = LocationData(
latitude: position.latitude,
longitude: position.longitude,
name: 'Current Location',
);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error getting location: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isGettingLocation = false);
}
}
}
Future<void> _openLocationPicker() async {
final result = await context.push<LocationPickerResult>('/location-picker');
if (result != null) {
setState(() {
_selectedLocation = LocationData(
latitude: result.position.latitude,
longitude: result.position.longitude,
name: result.address,
);
});
}
}
void _clearLocation() {
setState(() => _selectedLocation = null);
}
void _addStep() {
if (_stepController.text.trim().isEmpty) return;
setState(() {
_steps.add(GoalStep(
id: _uuid.v4(),
goalId: widget.goalId ?? '',
title: _stepController.text.trim(),
isDone: false,
orderIndex: _steps.length,
createdAt: DateTime.now(),
));
_stepController.clear();
});
}
void _removeStep(int index) {
setState(() {
_steps.removeAt(index);
for (int i = 0; i < _steps.length; i++) {
_steps[i] = _steps[i].copyWith(orderIndex: i);
}
});
}
void _toggleStepCompletion(int index) {
setState(() {
_steps[index] = _steps[index].copyWith(isDone: !_steps[index].isDone);
final completedSteps = _steps.where((s) => s.isDone).length;
_progress = _steps.isEmpty ? 0 : ((completedSteps / _steps.length) * 100).round();
});
}
Future<void> _saveGoal() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final goal = Goal(
id: widget.goalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
ownerId: 'current_user_id',
title: _titleController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
progress: _progress,
locationLat: _selectedLocation?.latitude,
locationLng: _selectedLocation?.longitude,
locationName: _selectedLocation?.name,
imageUrl: _selectedImagePath,
completed: _progress == 100,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (widget.goalId != null) {
await ref.read(goalsControllerProvider.notifier).updateGoal(goal);
} else {
await ref.read(goalsControllerProvider.notifier).createGoal(goal);
}
if (mounted) {
context.pop();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error saving goal: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_stepController.dispose();
_imageSearchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
title: widget.goalId == null ? 'Create Goal' : 'Edit Goal',
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
label: 'Goal title field',
hint: 'Enter your goal title',
child: TextFormField(
controller: _titleController,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Goal Title *',
hintText: 'e.g., Learn to play guitar',
prefixIcon: Icon(Icons.flag_outlined),
border: OutlineInputBorder(),
),
validator: Validators.validateGoalTitle,
enabled: !_isLoading,
),
),
const SizedBox(height: 16),
Semantics(
label: 'Goal description field',
hint: 'Enter a description for your goal',
child: TextFormField(
controller: _descriptionController,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'What do you want to achieve?',
prefixIcon: Icon(Icons.description_outlined),
border: OutlineInputBorder(),
),
validator: Validators.validateGoalDescription,
enabled: !_isLoading,
),
),
const SizedBox(height: 24),
Text(
'Cover Image (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (_selectedImagePath == null)
OutlinedButton.icon(
onPressed: _isLoading ? null : _showImagePickerDialog,
icon: const Icon(Icons.image_outlined),
label: const Text('Add Image'),
)
else
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
_selectedImagePath!.startsWith('http')
? File('')
: File(_selectedImagePath!),
width: double.infinity,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: double.infinity,
height: 200,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Center(
child: Icon(Icons.broken_image),
),
);
},
),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
),
onPressed: _clearImage,
),
),
],
),
const SizedBox(height: 24),
Text(
'Location (Optional)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (_selectedLocation == null)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isGettingLocation ? null : _getCurrentLocation,
icon: _isGettingLocation
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location),
label: const Text('Use Current Location'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoading ? null : _openLocationPicker,
icon: const Icon(Icons.map),
label: const Text('Pick on Map'),
),
),
],
)
else
Card(
child: ListTile(
leading: const Icon(Icons.location_on),
title: Text(_selectedLocation!.name),
subtitle: Text(
'${_selectedLocation!.latitude.toStringAsFixed(6)}, ${_selectedLocation!.longitude.toStringAsFixed(6)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearLocation,
),
),
),
const SizedBox(height: 24),
Text(
'Progress: $_progress%',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Slider(
value: _progress.toDouble(),
min: 0,
max: 100,
divisions: 100,
label: '$_progress%',
onChanged: (value) {
setState(() => _progress = value.toInt());
},
),
Text(
'Milestones/Steps',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _stepController,
decoration: const InputDecoration(
labelText: 'Add a step',
hintText: 'e.g., Complete first draft',
prefixIcon: Icon(Icons.add_task_outlined),
border: OutlineInputBorder(),
),
enabled: !_isLoading,
onSubmitted: (_) => _addStep(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _isLoading ? null : _addStep,
icon: const Icon(Icons.add_circle),
iconSize: 32,
color: Theme.of(context).colorScheme.primary,
),
],
),
const SizedBox(height: 16),
if (_steps.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'No steps added yet. Add steps to track your progress.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
...List.generate(_steps.length, (index) {
final step = _steps[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Checkbox(
value: step.isDone,
onChanged: _isLoading
? null
: (_) => _toggleStepCompletion(index),
),
title: Text(
step.title,
style: TextStyle(
decoration: step.isDone
? TextDecoration.lineThrough
: null,
color: step.isDone
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _isLoading
? null
: () => _removeStep(index),
),
),
);
}),
const SizedBox(height: 24),
const SizedBox(height: 24),
PrimaryButton(
onPressed: _isLoading ? () {} : _saveGoal,
text: _isLoading ? 'Saving...' : 'Save Goal',
isLoading: _isLoading,
),
],
),
),
),
),
);
}
}