CTK 1.2.0
This commit is contained in:
@@ -12,8 +12,7 @@ class Clinic {
|
|||||||
required this.lat,
|
required this.lat,
|
||||||
required this.lon,
|
required this.lon,
|
||||||
required this.address,
|
required this.address,
|
||||||
required this.phone,
|
required this.phoneNumbers,
|
||||||
required this.hasPhone,
|
|
||||||
required this.openingHours,
|
required this.openingHours,
|
||||||
required this.hasOpeningHours,
|
required this.hasOpeningHours,
|
||||||
this.distanceKm,
|
this.distanceKm,
|
||||||
@@ -24,11 +23,17 @@ class Clinic {
|
|||||||
final double lat;
|
final double lat;
|
||||||
final double lon;
|
final double lon;
|
||||||
final String address;
|
final String address;
|
||||||
final String phone;
|
|
||||||
final bool hasPhone;
|
/// Um consultório na OSM pode ter mais do que um contacto na mesma tag
|
||||||
|
/// `phone` (separados por `;`/`,`) — já vêm separados aqui, para quem usa
|
||||||
|
/// isto poder oferecer escolha em vez de tentar ligar para o texto todo
|
||||||
|
/// de uma vez. Lista vazia quando não há telefone.
|
||||||
|
final List<String> phoneNumbers;
|
||||||
final String openingHours;
|
final String openingHours;
|
||||||
final bool hasOpeningHours;
|
final bool hasOpeningHours;
|
||||||
|
|
||||||
|
bool get hasPhone => phoneNumbers.isNotEmpty;
|
||||||
|
|
||||||
/// Distância (km) até à morada guardada — só preenchida depois de
|
/// Distância (km) até à morada guardada — só preenchida depois de
|
||||||
/// [OverpassService.fetchNearbyClinics] a calcular; `null` até lá.
|
/// [OverpassService.fetchNearbyClinics] a calcular; `null` até lá.
|
||||||
final double? distanceKm;
|
final double? distanceKm;
|
||||||
@@ -40,8 +45,7 @@ class Clinic {
|
|||||||
lat: lat,
|
lat: lat,
|
||||||
lon: lon,
|
lon: lon,
|
||||||
address: address,
|
address: address,
|
||||||
phone: phone,
|
phoneNumbers: phoneNumbers,
|
||||||
hasPhone: hasPhone,
|
|
||||||
openingHours: openingHours,
|
openingHours: openingHours,
|
||||||
hasOpeningHours: hasOpeningHours,
|
hasOpeningHours: hasOpeningHours,
|
||||||
distanceKm: distanceKm,
|
distanceKm: distanceKm,
|
||||||
@@ -76,10 +80,16 @@ class Clinic {
|
|||||||
(tags['addr:city'] ?? tags['addr:suburb']),
|
(tags['addr:city'] ?? tags['addr:suburb']),
|
||||||
].whereType<String>().where((s) => s.trim().isNotEmpty).toList();
|
].whereType<String>().where((s) => s.trim().isNotEmpty).toList();
|
||||||
|
|
||||||
final phone = (tags['phone'] as String?)?.trim() ??
|
final phoneRaw = (tags['phone'] as String?)?.trim() ??
|
||||||
(tags['contact:phone'] as String?)?.trim();
|
(tags['contact:phone'] as String?)?.trim();
|
||||||
|
final phoneNumbers = (phoneRaw == null || phoneRaw.isEmpty)
|
||||||
|
? const <String>[]
|
||||||
|
: phoneRaw
|
||||||
|
.split(RegExp(r'[;,]'))
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
final openingHours = (tags['opening_hours'] as String?)?.trim();
|
final openingHours = (tags['opening_hours'] as String?)?.trim();
|
||||||
final hasPhone = phone != null && phone.isNotEmpty;
|
|
||||||
final hasOpeningHours = openingHours != null && openingHours.isNotEmpty;
|
final hasOpeningHours = openingHours != null && openingHours.isNotEmpty;
|
||||||
|
|
||||||
return Clinic(
|
return Clinic(
|
||||||
@@ -92,8 +102,7 @@ class Clinic {
|
|||||||
address: addressParts.isNotEmpty
|
address: addressParts.isNotEmpty
|
||||||
? addressParts.join(' • ')
|
? addressParts.join(' • ')
|
||||||
: ConsultoriosStrings.noAddress,
|
: ConsultoriosStrings.noAddress,
|
||||||
phone: hasPhone ? phone : ConsultoriosStrings.noPhone,
|
phoneNumbers: phoneNumbers,
|
||||||
hasPhone: hasPhone,
|
|
||||||
openingHours: hasOpeningHours
|
openingHours: hasOpeningHours
|
||||||
? openingHours
|
? openingHours
|
||||||
: ConsultoriosStrings.noOpeningHours,
|
: ConsultoriosStrings.noOpeningHours,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class ClinicCard extends StatelessWidget {
|
|||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final ValueChanged<String> onToggleFavorite;
|
final ValueChanged<String> onToggleFavorite;
|
||||||
|
|
||||||
Future<void> _call(BuildContext context) async {
|
Future<void> _call(BuildContext context, String number) async {
|
||||||
final uri = Uri(scheme: 'tel', path: clinic.phone);
|
final uri = Uri(scheme: 'tel', path: number);
|
||||||
try {
|
try {
|
||||||
final launched = await launchUrl(uri);
|
final launched = await launchUrl(uri);
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
@@ -35,6 +35,82 @@ class ClinicCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Um consultório na OSM pode listar vários contactos na mesma tag — em
|
||||||
|
/// vez de tentar ligar para o texto todo (o que nem é um número válido),
|
||||||
|
/// mostra-se este seletor para escolher qual dos números ligar.
|
||||||
|
Future<void> _showNumberPicker(BuildContext context) async {
|
||||||
|
final chosen = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
backgroundColor: AppColors.pinkBackground,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 4, 18, 18),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ConsultoriosStrings.chooseNumberTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.pink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (final number in clinic.phoneNumbers) ...[
|
||||||
|
TapBounce(
|
||||||
|
scale: 0.98,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () => Navigator.of(ctx).pop(number),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.call_rounded,
|
||||||
|
color: AppColors.teal,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (chosen != null && context.mounted) await _call(context, chosen);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -56,15 +132,6 @@ class ClinicCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.pinkBackground,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -132,45 +199,63 @@ class ClinicCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TapBounce(
|
_buildPhoneButton(context),
|
||||||
child: Material(
|
],
|
||||||
color: clinic.hasPhone
|
),
|
||||||
? AppColors.teal
|
);
|
||||||
: Colors.black.withValues(alpha: 0.06),
|
}
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
child: InkWell(
|
Widget _buildPhoneButton(BuildContext context) {
|
||||||
borderRadius: BorderRadius.circular(999),
|
final numbers = clinic.phoneNumbers;
|
||||||
onTap: clinic.hasPhone ? () => _call(context) : null,
|
final String label;
|
||||||
child: Padding(
|
final VoidCallback? onTap;
|
||||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
if (numbers.isEmpty) {
|
||||||
child: Row(
|
label = ConsultoriosStrings.noPhone;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onTap = null;
|
||||||
children: [
|
} else if (numbers.length == 1) {
|
||||||
Icon(
|
label = numbers.first;
|
||||||
Icons.call_rounded,
|
onTap = () => _call(context, numbers.first);
|
||||||
size: 16,
|
} else {
|
||||||
color: clinic.hasPhone
|
label = ConsultoriosStrings.chooseNumber(numbers.length);
|
||||||
? Colors.white
|
onTap = () => _showNumberPicker(context);
|
||||||
: Colors.black.withValues(alpha: 0.35),
|
}
|
||||||
),
|
final enabled = onTap != null;
|
||||||
const SizedBox(width: 8),
|
final color = enabled ? Colors.white : Colors.black.withValues(alpha: 0.35);
|
||||||
Text(
|
|
||||||
clinic.phone,
|
return TapBounce(
|
||||||
style: TextStyle(
|
child: Material(
|
||||||
fontWeight: FontWeight.w800,
|
color: enabled ? AppColors.teal : Colors.black.withValues(alpha: 0.06),
|
||||||
fontSize: 13,
|
borderRadius: BorderRadius.circular(999),
|
||||||
color: clinic.hasPhone
|
child: InkWell(
|
||||||
? Colors.white
|
borderRadius: BorderRadius.circular(999),
|
||||||
: Colors.black.withValues(alpha: 0.35),
|
onTap: onTap,
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 12),
|
||||||
],
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
enabled ? Icons.call_rounded : Icons.call_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 13,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,9 +83,6 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
|
|
||||||
QuizResultData? _lastResult;
|
QuizResultData? _lastResult;
|
||||||
|
|
||||||
int? _brushingWeekCount;
|
|
||||||
int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
|
||||||
bool _brushingDailyLimitReached = false;
|
|
||||||
int? _watchedVideoCount;
|
int? _watchedVideoCount;
|
||||||
|
|
||||||
String _cachedUserName = HomeStrings.noName;
|
String _cachedUserName = HomeStrings.noName;
|
||||||
@@ -124,32 +121,21 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
setState(() => _favoriteClinicIds = ids);
|
setState(() => _favoriteClinicIds = ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recarrega os dados locais de escovagem e vídeos assistidos da criança
|
/// Recarrega a contagem de vídeos assistidos da criança atualmente
|
||||||
/// atualmente selecionada. Chamado ao trocar de criança, ao editar a meta
|
/// selecionada. Chamado ao trocar de criança e depois de assistir um vídeo
|
||||||
/// semanal e depois de registar uma escovagem ou um vídeo completo.
|
/// completo.
|
||||||
Future<void> refreshStats() async {
|
Future<void> refreshStats() async {
|
||||||
final scope = (_selectedChildScopeId ?? '').trim();
|
final scope = (_selectedChildScopeId ?? '').trim();
|
||||||
if (scope.isEmpty) {
|
if (scope.isEmpty) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() => _watchedVideoCount = null);
|
||||||
_brushingWeekCount = null;
|
|
||||||
_weeklyGoal = BrushingPrefs.defaultWeeklyGoal;
|
|
||||||
_brushingDailyLimitReached = false;
|
|
||||||
_watchedVideoCount = null;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final weekCount = await BrushingPrefs.getWeekCount(scope);
|
|
||||||
final goal = await BrushingPrefs.getWeeklyGoal(scope);
|
|
||||||
final dailyLimitReached = await BrushingPrefs.hasReachedDailyLimit(scope);
|
|
||||||
final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope);
|
final watchedCount = await WatchedVideosPrefs.getWatchedCount(scope);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_brushingWeekCount = weekCount;
|
|
||||||
_weeklyGoal = goal;
|
|
||||||
_brushingDailyLimitReached = dailyLimitReached;
|
|
||||||
_watchedVideoCount = watchedCount;
|
_watchedVideoCount = watchedCount;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -458,64 +444,68 @@ class _LoggedHomeScreenState extends State<LoggedHomeScreen>
|
|||||||
bottom: Radius.circular(40),
|
bottom: Radius.circular(40),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
// Nota: o gradiente é pintado diretamente no slot `flexibleSpace`
|
||||||
titlePadding: EdgeInsets.zero,
|
// em vez de usar `FlexibleSpaceBar.background` — o FlexibleSpaceBar
|
||||||
background: ClipRRect(
|
// aplica um fade de opacidade e um deslocamento em parallax ao seu
|
||||||
borderRadius: const BorderRadius.vertical(
|
// `background` à medida que a barra colapsa, o que fazia o
|
||||||
bottom: Radius.circular(40),
|
// gradiente parecer diferente (e perder o border radius) a meio do
|
||||||
),
|
// scroll. Colocando o gradiente diretamente aqui, ele ocupa sempre
|
||||||
child: Container(
|
// exatamente a altura atual da barra, sem fade nem parallax.
|
||||||
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
flexibleSpace: ClipRRect(
|
||||||
child: Stack(
|
borderRadius: const BorderRadius.vertical(
|
||||||
fit: StackFit.expand,
|
bottom: Radius.circular(40),
|
||||||
children: [
|
),
|
||||||
if (hasScore)
|
child: Container(
|
||||||
Positioned(
|
decoration: const BoxDecoration(gradient: kAppBarGradient),
|
||||||
left: 0,
|
child: Stack(
|
||||||
right: 0,
|
fit: StackFit.expand,
|
||||||
top: kToolbarHeight + 50,
|
children: [
|
||||||
child: Center(
|
if (hasScore)
|
||||||
child: Text(
|
|
||||||
(_selectedChildName ?? '').trim(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: Colors.white.withValues(alpha: 0.92),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
//posição da app bar relativamente ao nome
|
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: kToolbarHeight + 96,
|
top: kToolbarHeight + 50,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Row(
|
child: Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
(_selectedChildName ?? '').trim(),
|
||||||
children: [
|
textAlign: TextAlign.center,
|
||||||
_RiskArcGauge(
|
style: TextStyle(
|
||||||
// Usa sempre o máximo atual do quiz (não
|
fontWeight: FontWeight.w800,
|
||||||
// o que foi gravado na última avaliação)
|
color: Colors.white.withValues(alpha: 0.92),
|
||||||
// para não mostrar um denominador antigo
|
fontSize: 14,
|
||||||
// quando o número de perguntas muda.
|
),
|
||||||
value: hasScore ? result.signs : null,
|
|
||||||
max: hasScore ? kSignsMax : null,
|
|
||||||
label: HomeStrings.signsGaugeLabel,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
_RiskArcGauge(
|
|
||||||
value: hasScore ? result.factors : null,
|
|
||||||
max: hasScore ? kFactorsMax : null,
|
|
||||||
label: HomeStrings.factorsGaugeLabel,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
//posição da app bar relativamente ao nome
|
||||||
),
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: kToolbarHeight + 96,
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_RiskArcGauge(
|
||||||
|
// Usa sempre o máximo atual do quiz (não
|
||||||
|
// o que foi gravado na última avaliação)
|
||||||
|
// para não mostrar um denominador antigo
|
||||||
|
// quando o número de perguntas muda.
|
||||||
|
value: hasScore ? result.signs : null,
|
||||||
|
max: hasScore ? kSignsMax : null,
|
||||||
|
label: HomeStrings.signsGaugeLabel,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
_RiskArcGauge(
|
||||||
|
value: hasScore ? result.factors : null,
|
||||||
|
max: hasScore ? kFactorsMax : null,
|
||||||
|
label: HomeStrings.factorsGaugeLabel,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -977,18 +967,6 @@ class _InicioTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
FadeSlideIn(
|
|
||||||
delay: const Duration(milliseconds: 70),
|
|
||||||
child: _StatsRow(
|
|
||||||
brushingCount: state?._brushingWeekCount,
|
|
||||||
weeklyGoal:
|
|
||||||
state?._weeklyGoal ?? BrushingPrefs.defaultWeeklyGoal,
|
|
||||||
brushedToday: state?._brushingDailyLimitReached ?? false,
|
|
||||||
watchedCount: state?._watchedVideoCount,
|
|
||||||
onTapBrushing: () => _logBrushing(context, state, scopeId),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FadeSlideIn(
|
FadeSlideIn(
|
||||||
delay: const Duration(milliseconds: 110),
|
delay: const Duration(milliseconds: 110),
|
||||||
@@ -1068,34 +1046,6 @@ class _InicioTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _logBrushing(
|
|
||||||
BuildContext context,
|
|
||||||
_LoggedHomeScreenState? state,
|
|
||||||
String? scopeId,
|
|
||||||
) async {
|
|
||||||
final scope = (scopeId ?? '').trim();
|
|
||||||
if (scope.isEmpty) {
|
|
||||||
final uid = (supabase.auth.currentUser?.id ?? '').trim();
|
|
||||||
if (uid.isEmpty) return;
|
|
||||||
await _requireFirstChild(context, uid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(await BrushingPrefs.canLogMore(scope))) {
|
|
||||||
if (context.mounted) {
|
|
||||||
showPillSnackBar(
|
|
||||||
context,
|
|
||||||
HomeStrings.brushingLimitReached(BrushingPrefs.maxPerDay),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await BrushingPrefs.logToday(scope);
|
|
||||||
await state?.refreshStats();
|
|
||||||
if (context.mounted) {
|
|
||||||
showPillSnackBar(context, HomeStrings.brushingLogged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Faz [child] "respirar" (escala sobe e desce suavemente, em loop) — usado
|
/// Faz [child] "respirar" (escala sobe e desce suavemente, em loop) — usado
|
||||||
@@ -1164,146 +1114,6 @@ class _HomeSectionLabel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatsRow extends StatelessWidget {
|
|
||||||
const _StatsRow({
|
|
||||||
required this.brushingCount,
|
|
||||||
required this.weeklyGoal,
|
|
||||||
required this.brushedToday,
|
|
||||||
required this.watchedCount,
|
|
||||||
required this.onTapBrushing,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int? brushingCount;
|
|
||||||
final int weeklyGoal;
|
|
||||||
final bool brushedToday;
|
|
||||||
final int? watchedCount;
|
|
||||||
final VoidCallback onTapBrushing;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return IntrinsicHeight(
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TapBounce(
|
|
||||||
scale: 0.97,
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.brush_rounded,
|
|
||||||
iconColor: AppColors.pink,
|
|
||||||
value: brushingCount == null
|
|
||||||
? '--'
|
|
||||||
: '$brushingCount/$weeklyGoal',
|
|
||||||
label: HomeStrings.brushingThisWeek,
|
|
||||||
done: brushedToday,
|
|
||||||
onTap: onTapBrushing,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.movie_filter_rounded,
|
|
||||||
iconColor: AppColors.purple,
|
|
||||||
value: watchedCount == null ? '--' : '$watchedCount',
|
|
||||||
label: HomeStrings.completedEpisodes,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
|
||||||
const _StatCard({
|
|
||||||
required this.icon,
|
|
||||||
required this.iconColor,
|
|
||||||
required this.value,
|
|
||||||
required this.label,
|
|
||||||
this.onTap,
|
|
||||||
this.done = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final Color iconColor;
|
|
||||||
final String value;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final bool done;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Material(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
elevation: 8,
|
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.10),
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
onTap: onTap,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.14),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: iconColor, size: 20),
|
|
||||||
),
|
|
||||||
if (done)
|
|
||||||
Positioned(
|
|
||||||
top: -4,
|
|
||||||
right: -4,
|
|
||||||
child: Container(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.teal,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.check_rounded,
|
|
||||||
size: 11,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
fontSize: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11.5,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> _createChildViaSheet(
|
Future<Map<String, dynamic>?> _createChildViaSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String uid,
|
String uid,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class _HelloSplashScreenState extends State<HelloSplashScreen> with TickerProvid
|
|||||||
|
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 250),
|
||||||
);
|
);
|
||||||
|
|
||||||
_opacity = CurvedAnimation(
|
_opacity = CurvedAnimation(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:youtube_player_flutter/youtube_player_flutter.dart';
|
|||||||
|
|
||||||
import '../watched_videos_prefs.dart';
|
import '../watched_videos_prefs.dart';
|
||||||
import '../colors/app_gradients.dart';
|
import '../colors/app_gradients.dart';
|
||||||
|
import '../strings/home_strings.dart';
|
||||||
import '../strings/video_strings.dart';
|
import '../strings/video_strings.dart';
|
||||||
import '../widgets/entrance.dart';
|
import '../widgets/entrance.dart';
|
||||||
import '../widgets/liquid_waves_background.dart';
|
import '../widgets/liquid_waves_background.dart';
|
||||||
@@ -213,12 +214,25 @@ class VideoScreen extends StatefulWidget {
|
|||||||
class _VideoScreenState extends State<VideoScreen> {
|
class _VideoScreenState extends State<VideoScreen> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
List<VideoData> _filteredVideos = videoList;
|
List<VideoData> _filteredVideos = videoList;
|
||||||
|
int? _watchedCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_filteredVideos = videoList;
|
_filteredVideos = videoList;
|
||||||
_searchController.addListener(_onSearchChanged);
|
_searchController.addListener(_onSearchChanged);
|
||||||
|
_loadWatchedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadWatchedCount() async {
|
||||||
|
final scope = (widget.scopeId ?? '').trim();
|
||||||
|
if (scope.isEmpty) {
|
||||||
|
if (mounted) setState(() => _watchedCount = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final count = await WatchedVideosPrefs.getWatchedCount(scope);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _watchedCount = count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -302,6 +316,10 @@ class _VideoScreenState extends State<VideoScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if ((widget.scopeId ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_CompletedEpisodesCard(count: _watchedCount),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Lista vertical (um vídeo abaixo do outro, rolando para
|
// Lista vertical (um vídeo abaixo do outro, rolando para
|
||||||
// baixo), mas cada card em si é horizontal — miniatura à
|
// baixo), mas cada card em si é horizontal — miniatura à
|
||||||
@@ -329,6 +347,7 @@ class _VideoScreenState extends State<VideoScreen> {
|
|||||||
child: _VideoButton(
|
child: _VideoButton(
|
||||||
video: _filteredVideos[index],
|
video: _filteredVideos[index],
|
||||||
scopeId: widget.scopeId,
|
scopeId: widget.scopeId,
|
||||||
|
onVideoClosed: _loadWatchedCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -344,6 +363,66 @@ class _VideoScreenState extends State<VideoScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cartão com o número de episódios já assistidos até ao fim pela criança
|
||||||
|
/// selecionada — vivia antes na Home como um dos dois "stat cards", mas
|
||||||
|
/// mudou-se para aqui (a própria biblioteca de vídeos) por ser mais
|
||||||
|
/// relevante neste contexto.
|
||||||
|
class _CompletedEpisodesCard extends StatelessWidget {
|
||||||
|
const _CompletedEpisodesCard({required this.count});
|
||||||
|
|
||||||
|
final int? count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.purple.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.movie_filter_rounded,
|
||||||
|
color: AppColors.purple,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
count == null ? '--' : '$count',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
HomeStrings.completedEpisodes,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Preview de um vídeo (frame local ou thumbnail do YouTube), reutilizável
|
/// Preview de um vídeo (frame local ou thumbnail do YouTube), reutilizável
|
||||||
/// em qualquer card que precise mostrar "a cara" de um episódio.
|
/// em qualquer card que precise mostrar "a cara" de um episódio.
|
||||||
class VideoThumbnail extends StatefulWidget {
|
class VideoThumbnail extends StatefulWidget {
|
||||||
@@ -509,18 +588,20 @@ class _VideoThumbnailState extends State<VideoThumbnail> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VideoButton extends StatelessWidget {
|
class _VideoButton extends StatelessWidget {
|
||||||
const _VideoButton({required this.video, this.scopeId});
|
const _VideoButton({required this.video, this.scopeId, this.onVideoClosed});
|
||||||
|
|
||||||
final VideoData video;
|
final VideoData video;
|
||||||
final String? scopeId;
|
final String? scopeId;
|
||||||
|
final VoidCallback? onVideoClosed;
|
||||||
|
|
||||||
void _showVideoPlayer(BuildContext context, VideoData video) {
|
Future<void> _showVideoPlayer(BuildContext context, VideoData video) async {
|
||||||
if (video.youtubeId == null) {
|
if (video.youtubeId == null) {
|
||||||
// Liberta todos os decodificadores de prévia antes de abrir o player
|
// Liberta todos os decodificadores de prévia antes de abrir o player
|
||||||
// em dialog, que precisa dos seus próprios decoders de vídeo/áudio.
|
// em dialog, que precisa dos seus próprios decoders de vídeo/áudio.
|
||||||
_evictAllVideoControllers();
|
_evictAllVideoControllers();
|
||||||
}
|
}
|
||||||
showVideoPlayerDialog(context, video, scopeId: scopeId);
|
await showVideoPlayerDialog(context, video, scopeId: scopeId);
|
||||||
|
onVideoClosed?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class ConsultoriosStrings {
|
|||||||
static const String navConsultorios = 'Consultórios';
|
static const String navConsultorios = 'Consultórios';
|
||||||
static const String pageTitle = 'Consultórios';
|
static const String pageTitle = 'Consultórios';
|
||||||
|
|
||||||
static String _localCount(int count) => '$count locais${count == 1 ? '' : 'is'}';
|
static String _localCount(int count) => '$count loca${count == 1 ? 'l' : 'is'}';
|
||||||
static String sectionNear(int count) => 'A menos de 5km · ${_localCount(count)}';
|
static String sectionNear(int count) => 'A menos de 5km · ${_localCount(count)}';
|
||||||
static String sectionFar(int count) => 'Mais distantes · ${_localCount(count)}';
|
static String sectionFar(int count) => 'Mais distantes · ${_localCount(count)}';
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ class ConsultoriosStrings {
|
|||||||
static const String noPhoneAppAvailable = 'Não foi possível abrir a app de telefone';
|
static const String noPhoneAppAvailable = 'Não foi possível abrir a app de telefone';
|
||||||
static String callFailed(Object e) => 'Não foi possível ligar: $e';
|
static String callFailed(Object e) => 'Não foi possível ligar: $e';
|
||||||
static const String distanceKm = 'km';
|
static const String distanceKm = 'km';
|
||||||
|
static String chooseNumber(int count) => 'Escolher número ($count)';
|
||||||
|
static const String chooseNumberTitle = 'Para que número quer ligar?';
|
||||||
|
|
||||||
static const String noAddressTitle = 'Ainda não registou uma morada';
|
static const String noAddressTitle = 'Ainda não registou uma morada';
|
||||||
static const String noAddressMessage =
|
static const String noAddressMessage =
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.2.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user