/opt/canhelp/apps/mobile/lib/screens/home
Edit: /opt/canhelp/apps/mobile/lib/screens/home/home_screen.dart (59772B)
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../models/location.dart';
import '../../models/task.dart';
import '../../models/user.dart';
import '../../providers/auth_provider.dart';
import '../../providers/favorites_provider.dart';
import '../../providers/locale_provider.dart';
import '../../providers/locations_provider.dart';
import '../../providers/notifications_provider.dart';
import '../../services/api_client.dart';
import '../../services/storage_service.dart';
import '../../theme/app_theme.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State
createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
List _nearbyTasks = [];
bool _loading = true;
String? _selectedLocationSlug;
bool _usingGps = false;
bool _manualLocationSelected = false;
final StorageService _storage = StorageService();
List _specialists = [];
bool _loadingSpecialists = false;
// ── Greek city centres (slug → lat/lng) ──────────────────────
static const Map> _cityCentres = {
'athens': [37.9838, 23.7275],
'thessaloniki': [40.6401, 22.9444],
'patras': [38.2466, 21.7346],
'heraklion': [35.3387, 25.1442],
'larissa': [39.6390, 22.4191],
'volos': [39.3667, 22.9333],
'ioannina': [39.6650, 20.8537],
'chania': [35.5138, 24.0180],
'rhodes': [36.4341, 28.2176],
'kavala': [40.9396, 24.4023],
};
@override
void initState() {
super.initState();
_bootstrapLocation();
}
Future _bootstrapLocation() async {
final savedLocation = await _storage.getSelectedLocation();
if (!mounted) return;
if (savedLocation != null) {
setState(() {
_selectedLocationSlug = savedLocation;
_usingGps = false;
});
await _loadTasks(location: savedLocation);
await _loadSpecialists(location: savedLocation);
return;
}
await _loadTasks();
await _loadSpecialists();
await _detectCityFromGps();
}
Future _loadTasks({String? location}) async {
setState(() => _loading = true);
try {
final result = await ApiClient().getTasks(
status: 'open',
limit: 12,
location: location ?? _selectedLocationSlug,
);
if (mounted) setState(() => _nearbyTasks = result.data);
} catch (_) {
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future _loadSpecialists({String? location}) async {
setState(() => _loadingSpecialists = true);
try {
final locale = context.read().locale;
final result = await ApiClient().getSpecialists(
locale: locale,
location: location ?? _selectedLocationSlug,
limit: 10,
);
if (mounted) setState(() => _specialists = result.data);
} catch (_) {
} finally {
if (mounted) setState(() => _loadingSpecialists = false);
}
}
Future _detectCityFromGps() async {
try {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return;
}
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: Duration(seconds: 8),
),
);
// Find nearest city
String? nearest;
double minDist = double.infinity;
for (final entry in _cityCentres.entries) {
final d = _haversine(
pos.latitude,
pos.longitude,
entry.value[0],
entry.value[1],
);
if (d < minDist) {
minDist = d;
nearest = entry.key;
}
}
if (nearest != null && mounted) {
if (_manualLocationSelected) return;
setState(() {
_selectedLocationSlug = nearest;
_usingGps = true;
});
await _storage.saveSelectedLocation(nearest);
_loadTasks(location: nearest);
_loadSpecialists(location: nearest);
}
} catch (_) {
// Silently ignore — city picker still works manually
}
}
static double _haversine(double lat1, double lng1, double lat2, double lng2) {
const r = 6371.0;
final dLat = (lat2 - lat1) * pi / 180;
final dLng = (lng2 - lng1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLng / 2) *
sin(dLng / 2);
return r * 2 * atan2(sqrt(a), sqrt(1 - a));
}
void _showCityPicker() {
final locProvider = context.read();
final locale = context.read().locale;
final t = context.read().t;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => _CityPickerSheet(
locations: locProvider.locations,
selectedSlug: _selectedLocationSlug,
locale: locale,
anyLabel: t('tasks.anyLocation'),
onSelect: (slug) async {
setState(() {
_selectedLocationSlug = slug;
_manualLocationSelected = true;
});
_usingGps = false;
await _storage.saveSelectedLocation(slug);
_loadTasks(location: slug);
_loadSpecialists(location: slug);
},
),
);
}
@override
Widget build(BuildContext context) {
final locale = context.watch();
final t = locale.t;
final user = context.watch().user;
final favorites = context.watch().favorites;
final screenWidth = MediaQuery.sizeOf(context).width;
final isTablet = screenWidth >= 700;
final visibleTaskCount = isTablet ? 8 : 6;
final firstName = user?.firstName ?? '';
final locProvider = context.watch();
final cityLabel = _selectedLocationSlug != null
? locProvider.nameFor(_selectedLocationSlug, locale.locale)
: t('tasks.anyLocation');
final unreadNotifs = context.watch().unreadCount;
return Scaffold(
backgroundColor: AppTheme.background,
body: SafeArea(
child: CustomScrollView(
slivers: [
// ── Header ────────────────────────────────────────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: _showCityPicker,
child: Row(
children: [
Icon(
_usingGps
? Icons.my_location_rounded
: Icons.location_on_rounded,
size: 16,
color: AppTheme.primary,
),
const SizedBox(width: 4),
Text(
cityLabel,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const Icon(
Icons.keyboard_arrow_down_rounded,
size: 16,
color: AppTheme.textSecondary,
),
],
),
),
const SizedBox(height: 4),
Text(
firstName.isNotEmpty
? t(
'home.greeting',
).replaceAll('{name}', firstName)
: t('home.greetingAnon'),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
height: 1.2,
),
),
],
),
),
// Notification bell
Stack(
clipBehavior: Clip.none,
children: [
GestureDetector(
onTap: () => context.push('/notifications'),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.border),
),
child: const Icon(
Icons.notifications_none_rounded,
size: 22,
color: AppTheme.textPrimary,
),
),
),
if (unreadNotifs > 0)
Positioned(
right: 0,
top: 0,
child: Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: AppTheme.danger,
shape: BoxShape.circle,
),
child: Center(
child: Text(
unreadNotifs > 9 ? '9+' : '$unreadNotifs',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
],
),
),
),
// ── Search bar (read-only, taps to search tab) ────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: GestureDetector(
onTap: () => context.go('/search'),
child: Container(
height: 50,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.inputRadius),
border: Border.all(color: AppTheme.border),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: Icon(
Icons.search_rounded,
color: AppTheme.textHint,
size: 22,
),
),
Text(
t('home.searchPlaceholder'),
style: const TextStyle(
fontSize: 15,
color: AppTheme.textHint,
),
),
],
),
),
),
),
),
// ── Post a request button ─────────────────────────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 54,
child: ElevatedButton.icon(
onPressed: () => context.push('/tasks/new'),
icon: const Icon(Icons.add_rounded, size: 22),
label: Text(
t('tasks.createTask'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
),
),
if (user != null) ...[
const SizedBox(height: 8),
SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: () => context.push('/dashboard'),
icon: const Icon(Icons.assignment_outlined, size: 20),
label: Text(
t('dashboard.myTasks'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
),
),
],
],
),
),
),
// ── Near you (horizontal scroll) ──────────────────────
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
t('home.nearYou'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
GestureDetector(
onTap: () => context.go('/search'),
child: Text(
t('home.seeAll'),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => context.push('/map'),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Icon(
Icons.map_rounded,
size: 13,
color: AppTheme.primary,
),
const SizedBox(width: 4),
Text(
t('home.map'),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
],
),
),
),
],
),
),
),
if (_loading)
const SliverToBoxAdapter(
child: SizedBox(
height: 140,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primary,
),
),
),
)
else if (_nearbyTasks.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
AppTheme.cardRadius,
),
border: Border.all(color: AppTheme.border),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(
AppTheme.smallRadius,
),
),
child: const Icon(
Icons.explore_outlined,
color: AppTheme.primary,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
t('home.noTasksNearbyTitle'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
),
],
),
const SizedBox(height: 10),
Text(
t('tasks.emptyMessage'),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
height: 1.35,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => context.push('/map'),
icon: const Icon(Icons.map_rounded, size: 18),
label: Text(
t('home.openMapCta'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
foregroundColor: AppTheme.primary,
side: const BorderSide(
color: AppTheme.primary,
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
),
),
],
),
),
const SizedBox(height: 12),
if (user == null || !user.isSpecialist)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
AppTheme.cardRadius,
),
border: Border.all(
color: AppTheme.primary.withValues(alpha: 0.28),
),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(
AppTheme.smallRadius,
),
),
child: const Icon(
Icons.workspace_premium_outlined,
color: AppTheme.primary,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
t('home.becomeSpecialistTitle'),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
),
],
),
const SizedBox(height: 10),
Text(
t('home.becomeSpecialistSubtitle'),
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
height: 1.35,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () =>
context.push('/become-specialist'),
icon: const Icon(
Icons.trending_up_rounded,
size: 18,
),
label: Text(t('home.becomeSpecialistCta')),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
foregroundColor: AppTheme.primary,
side: const BorderSide(
color: AppTheme.primary,
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.buttonRadius,
),
),
),
),
),
],
),
),
],
),
),
)
else
SliverToBoxAdapter(
child: SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _nearbyTasks.take(visibleTaskCount).length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, i) {
return _TaskCard(
task: _nearbyTasks[i],
onTap: () =>
context.push('/tasks/${_nearbyTasks[i].id}'),
);
},
),
),
),
// ── Recent requests (vertical list) ───────────────────
if (_nearbyTasks.length > visibleTaskCount) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
child: Text(
t('home.recentRequests'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, i) {
final task = _nearbyTasks[i + visibleTaskCount];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _TaskListItem(
task: task,
onTap: () => context.push('/tasks/${task.id}'),
),
);
}, childCount: _nearbyTasks.skip(visibleTaskCount).length),
),
),
],
// ── Specialists / Offers ──────────────────────────────
if (_loadingSpecialists || _specialists.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
t('nav.specialists'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
GestureDetector(
onTap: () => context.go('/search?mode=offers'),
child: Text(
t('home.seeAll'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
],
),
),
),
if (_loadingSpecialists)
const SliverToBoxAdapter(
child: SizedBox(
height: 90,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppTheme.primary,
),
),
),
)
else
SliverToBoxAdapter(
child: SizedBox(
height: isTablet ? 156 : 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _specialists.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, i) {
final s = _specialists[i];
final initials =
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}'
.toUpperCase();
final categorySlug =
s.specialistCategories != null &&
s.specialistCategories!.isNotEmpty
? s.specialistCategories!.first
: null;
final categoryLabel = categorySlug != null
? t('category.$categorySlug') ==
'category.$categorySlug'
? categorySlug
: t('category.$categorySlug')
: null;
final summary =
(s.bio != null && s.bio!.trim().isNotEmpty)
? s.bio!.trim()
: categoryLabel;
final ratingLabel = s.rating > 0
? s.rating.toStringAsFixed(1)
: null;
return GestureDetector(
onTap: () => context.push('/specialists/${s.id}'),
child: Container(
width: isTablet ? 136 : 110,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
AppTheme.cardRadius,
),
border: Border.all(color: AppTheme.border),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Stack(
children: [
CircleAvatar(
radius: isTablet ? 30 : 27,
backgroundImage: s.avatar != null
? NetworkImage(s.avatar!)
: null,
backgroundColor:
AppTheme.primaryContainer,
child: s.avatar == null
? Text(
initials.isNotEmpty
? initials
: '?',
style: const TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: null,
),
if (s.isOnline)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 11,
height: 11,
decoration: BoxDecoration(
color: AppTheme.success,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
),
),
],
),
const SizedBox(height: 8),
Text(
s.firstName.isNotEmpty
? s.firstName
: s.fullName.split(' ').first,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (summary != null) ...[
const SizedBox(height: 4),
Text(
summary,
style: TextStyle(
fontSize: isTablet ? 11.5 : 11,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
maxLines: isTablet ? 2 : 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
if (ratingLabel != null) ...[
const SizedBox(height: 5),
Text(
'★ $ratingLabel',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppTheme.amber,
),
),
],
],
),
),
);
},
),
),
),
],
// ── Favourites ────────────────────────────────────────
if (user != null && favorites.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 28, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
t('nav.favorites'),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
GestureDetector(
onTap: () => context.push('/favorites'),
child: Text(
t('home.seeAll'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
),
],
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 90,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, i) {
final s = favorites[i];
final initials =
'${s.firstName.isNotEmpty ? s.firstName[0] : ''}${s.lastName.isNotEmpty ? s.lastName[0] : ''}'
.toUpperCase();
return GestureDetector(
onTap: () => context.push('/specialists/${s.id}'),
child: SizedBox(
width: 70,
child: Column(
children: [
Stack(
children: [
CircleAvatar(
radius: 26,
backgroundImage: s.avatar != null
? NetworkImage(s.avatar!)
: null,
backgroundColor: AppTheme.primaryContainer,
child: s.avatar == null
? Text(
initials.isNotEmpty
? initials
: '?',
style: const TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: null,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: AppTheme.amber,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
child: const Icon(
Icons.favorite_rounded,
size: 7,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 5),
Text(
s.firstName.isNotEmpty
? s.firstName
: s.fullName.split(' ').first,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
);
},
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
}
// ─── Task card (horizontal scroll) ─────────────────────────────────────────
class _TaskCard extends StatelessWidget {
final Task task;
final VoidCallback onTap;
const _TaskCard({required this.task, required this.onTap});
@override
Widget build(BuildContext context) {
final color = AppTheme.categoryColor(task.category);
final t = context.watch().t;
return GestureDetector(
onTap: onTap,
child: Container(
width: 180,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
border: Border.all(color: AppTheme.border),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category icon
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppTheme.smallRadius),
),
child: Icon(
AppTheme.categoryIcon(task.category),
color: color,
size: 20,
),
),
const Spacer(),
// Title
Text(
task.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
// Price
if (task.budget != null)
Text(
'€${task.budget!.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppTheme.primary,
),
)
else
Text(
t('tasks.negotiable'),
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
),
),
);
}
}
// ─── Task list item (vertical list) ─────────────────────────────────────────
class _TaskListItem extends StatelessWidget {
final Task task;
final VoidCallback onTap;
const _TaskListItem({required this.task, required this.onTap});
@override
Widget build(BuildContext context) {
final color = AppTheme.categoryColor(task.category);
final locale = context.watch().locale;
final locationLabel = context.watch().nameFor(
task.location,
locale,
);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.cardRadius),
border: Border.all(color: AppTheme.border),
boxShadow: const [
BoxShadow(
color: AppTheme.shadowColor,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppTheme.smallRadius),
),
child: Icon(
AppTheme.categoryIcon(task.category),
color: color,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (task.location != null)
Row(
children: [
const Icon(
Icons.location_on_rounded,
size: 13,
color: AppTheme.textHint,
),
const SizedBox(width: 2),
Text(
locationLabel,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
],
),
),
const SizedBox(width: 8),
if (task.budget != null)
Text(
'€${task.budget!.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppTheme.primary,
),
)
else
const Icon(
Icons.handshake_outlined,
size: 18,
color: AppTheme.textHint,
),
],
),
),
);
}
}
// ─── City picker bottom sheet ───────────────────────────────────────────────
class _CityPickerSheet extends StatefulWidget {
final List locations;
final String? selectedSlug;
final String locale;
final String anyLabel;
final ValueChanged onSelect;
const _CityPickerSheet({
required this.locations,
required this.selectedSlug,
required this.locale,
required this.anyLabel,
required this.onSelect,
});
@override
State<_CityPickerSheet> createState() => _CityPickerSheetState();
}
class _CityPickerSheetState extends State<_CityPickerSheet> {
String? _expandedCity;
@override
void initState() {
super.initState();
// Auto-expand city of selected district
if (widget.selectedSlug != null) {
for (final city in widget.locations) {
for (final d in city.children) {
if (d.slug == widget.selectedSlug) {
_expandedCity = city.slug;
break;
}
}
}
}
}
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.75;
return Container(
constraints: BoxConstraints(maxHeight: maxH),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.border,
borderRadius: BorderRadius.circular(2),
),
),
// Title
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.read().t('home.selectCity'),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
),
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 22),
color: AppTheme.textSecondary,
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1, color: AppTheme.border),
// List
Flexible(
child: ListView(
shrinkWrap: true,
children: [
// "Any location" row
_LocationRow(
label: widget.anyLabel,
icon: Icons.public_rounded,
selected: widget.selectedSlug == null,
onTap: () {
widget.onSelect(null);
Navigator.pop(context);
},
),
const Divider(height: 1, color: AppTheme.border),
// Cities
...widget.locations.map((city) {
final cityName = city.nameFor(widget.locale);
final hasDistricts = city.children.isNotEmpty;
final isExpanded = _expandedCity == city.slug;
final isCitySelected = widget.selectedSlug == city.slug;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_LocationRow(
label: cityName,
icon: Icons.location_city_rounded,
selected: isCitySelected,
trailing: hasDistricts
? Icon(
isExpanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 20,
color: AppTheme.textHint,
)
: null,
onTap: () {
if (hasDistricts) {
setState(
() =>
_expandedCity = isExpanded ? null : city.slug,
);
} else {
widget.onSelect(city.slug);
Navigator.pop(context);
}
},
),
// Districts
if (hasDistricts && isExpanded)
Column(
mainAxisSize: MainAxisSize.min,
children: [
// "Whole city" option
_LocationRow(
label: '$cityName — all',
icon: Icons.circle,
iconSize: 6,
selected: isCitySelected,
indent: 32,
onTap: () {
widget.onSelect(city.slug);
Navigator.pop(context);
},
),
...city.children.map(
(d) => _LocationRow(
label: d.nameFor(widget.locale),
icon: Icons.circle,
iconSize: 6,
selected: widget.selectedSlug == d.slug,
indent: 32,
onTap: () {
widget.onSelect(d.slug);
Navigator.pop(context);
},
),
),
],
),
const Divider(height: 1, color: AppTheme.border),
],
);
}),
],
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 8),
],
),
);
}
}
class _LocationRow extends StatelessWidget {
final String label;
final IconData icon;
final double iconSize;
final bool selected;
final double indent;
final Widget? trailing;
final VoidCallback onTap;
const _LocationRow({
required this.label,
required this.icon,
this.iconSize = 20,
required this.selected,
this.indent = 0,
this.trailing,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: selected ? AppTheme.primaryLight : Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: EdgeInsets.fromLTRB(20 + indent, 14, 16, 14),
child: Row(
children: [
Icon(
icon,
size: iconSize,
color: selected ? AppTheme.primary : AppTheme.textHint,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? AppTheme.primary : AppTheme.textPrimary,
),
),
),
if (selected)
const Icon(
Icons.check_rounded,
size: 18,
color: AppTheme.primary,
),
if (trailing != null && !selected) trailing!,
],
),
),
),
);
}
}