mirror of
https://github.com/Dvorinka/1356.git
synced 2026-06-04 12:02:56 +00:00
37ffb93923
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.
907 lines
34 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|