diff --git a/lib/consultorios/clinic.dart b/lib/consultorios/clinic.dart index 396aa40..53f488f 100644 --- a/lib/consultorios/clinic.dart +++ b/lib/consultorios/clinic.dart @@ -12,8 +12,7 @@ class Clinic { required this.lat, required this.lon, required this.address, - required this.phone, - required this.hasPhone, + required this.phoneNumbers, required this.openingHours, required this.hasOpeningHours, this.distanceKm, @@ -24,11 +23,17 @@ class Clinic { final double lat; final double lon; 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 phoneNumbers; final String openingHours; final bool hasOpeningHours; + bool get hasPhone => phoneNumbers.isNotEmpty; + /// Distância (km) até à morada guardada — só preenchida depois de /// [OverpassService.fetchNearbyClinics] a calcular; `null` até lá. final double? distanceKm; @@ -40,8 +45,7 @@ class Clinic { lat: lat, lon: lon, address: address, - phone: phone, - hasPhone: hasPhone, + phoneNumbers: phoneNumbers, openingHours: openingHours, hasOpeningHours: hasOpeningHours, distanceKm: distanceKm, @@ -76,10 +80,16 @@ class Clinic { (tags['addr:city'] ?? tags['addr:suburb']), ].whereType().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(); + final phoneNumbers = (phoneRaw == null || phoneRaw.isEmpty) + ? const [] + : phoneRaw + .split(RegExp(r'[;,]')) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); final openingHours = (tags['opening_hours'] as String?)?.trim(); - final hasPhone = phone != null && phone.isNotEmpty; final hasOpeningHours = openingHours != null && openingHours.isNotEmpty; return Clinic( @@ -92,8 +102,7 @@ class Clinic { address: addressParts.isNotEmpty ? addressParts.join(' • ') : ConsultoriosStrings.noAddress, - phone: hasPhone ? phone : ConsultoriosStrings.noPhone, - hasPhone: hasPhone, + phoneNumbers: phoneNumbers, openingHours: hasOpeningHours ? openingHours : ConsultoriosStrings.noOpeningHours, diff --git a/lib/consultorios/clinic_card.dart b/lib/consultorios/clinic_card.dart index 8de9154..28e7935 100644 --- a/lib/consultorios/clinic_card.dart +++ b/lib/consultorios/clinic_card.dart @@ -23,8 +23,8 @@ class ClinicCard extends StatelessWidget { final bool isFavorite; final ValueChanged onToggleFavorite; - Future _call(BuildContext context) async { - final uri = Uri(scheme: 'tel', path: clinic.phone); + Future _call(BuildContext context, String number) async { + final uri = Uri(scheme: 'tel', path: number); try { final launched = await launchUrl(uri); 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 _showNumberPicker(BuildContext context) async { + final chosen = await showModalBottomSheet( + 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 Widget build(BuildContext context) { return Container( @@ -56,15 +132,6 @@ class ClinicCard extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppColors.pinkBackground, - borderRadius: BorderRadius.circular(14), - ), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -132,45 +199,63 @@ class ClinicCard extends StatelessWidget { ], ), const SizedBox(height: 10), - TapBounce( - child: Material( - color: clinic.hasPhone - ? AppColors.teal - : Colors.black.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - child: InkWell( - borderRadius: BorderRadius.circular(999), - onTap: clinic.hasPhone ? () => _call(context) : null, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.call_rounded, - size: 16, - color: clinic.hasPhone - ? Colors.white - : Colors.black.withValues(alpha: 0.35), - ), - const SizedBox(width: 8), - Text( - clinic.phone, - style: TextStyle( - fontWeight: FontWeight.w800, - fontSize: 13, - color: clinic.hasPhone - ? Colors.white - : Colors.black.withValues(alpha: 0.35), - ), - ), - ], + _buildPhoneButton(context), + ], + ), + ); + } + + Widget _buildPhoneButton(BuildContext context) { + final numbers = clinic.phoneNumbers; + final String label; + final VoidCallback? onTap; + if (numbers.isEmpty) { + label = ConsultoriosStrings.noPhone; + onTap = null; + } else if (numbers.length == 1) { + label = numbers.first; + onTap = () => _call(context, numbers.first); + } else { + label = ConsultoriosStrings.chooseNumber(numbers.length); + onTap = () => _showNumberPicker(context); + } + final enabled = onTap != null; + final color = enabled ? Colors.white : Colors.black.withValues(alpha: 0.35); + + return TapBounce( + child: Material( + color: enabled ? AppColors.teal : Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + child: InkWell( + borderRadius: BorderRadius.circular(999), + 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, + ), ), ), - ), + ], ), ), - ], + ), ), ); } diff --git a/lib/logged_home.dart b/lib/logged_home.dart index 76ac69f..3725451 100644 --- a/lib/logged_home.dart +++ b/lib/logged_home.dart @@ -83,9 +83,6 @@ class _LoggedHomeScreenState extends State QuizResultData? _lastResult; - int? _brushingWeekCount; - int _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; - bool _brushingDailyLimitReached = false; int? _watchedVideoCount; String _cachedUserName = HomeStrings.noName; @@ -124,32 +121,21 @@ class _LoggedHomeScreenState extends State setState(() => _favoriteClinicIds = ids); } - /// Recarrega os dados locais de escovagem e vídeos assistidos da criança - /// atualmente selecionada. Chamado ao trocar de criança, ao editar a meta - /// semanal e depois de registar uma escovagem ou um vídeo completo. + /// Recarrega a contagem de vídeos assistidos da criança atualmente + /// selecionada. Chamado ao trocar de criança e depois de assistir um vídeo + /// completo. Future refreshStats() async { final scope = (_selectedChildScopeId ?? '').trim(); if (scope.isEmpty) { if (!mounted) return; - setState(() { - _brushingWeekCount = null; - _weeklyGoal = BrushingPrefs.defaultWeeklyGoal; - _brushingDailyLimitReached = false; - _watchedVideoCount = null; - }); + setState(() => _watchedVideoCount = null); 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); if (!mounted) return; setState(() { - _brushingWeekCount = weekCount; - _weeklyGoal = goal; - _brushingDailyLimitReached = dailyLimitReached; _watchedVideoCount = watchedCount; }); } @@ -458,64 +444,68 @@ class _LoggedHomeScreenState extends State bottom: Radius.circular(40), ), ), - flexibleSpace: FlexibleSpaceBar( - titlePadding: EdgeInsets.zero, - background: ClipRRect( - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(40), - ), - child: Container( - decoration: const BoxDecoration(gradient: kAppBarGradient), - child: Stack( - fit: StackFit.expand, - children: [ - if (hasScore) - Positioned( - left: 0, - right: 0, - top: kToolbarHeight + 50, - child: Center( - 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 + // Nota: o gradiente é pintado diretamente no slot `flexibleSpace` + // em vez de usar `FlexibleSpaceBar.background` — o FlexibleSpaceBar + // aplica um fade de opacidade e um deslocamento em parallax ao seu + // `background` à medida que a barra colapsa, o que fazia o + // gradiente parecer diferente (e perder o border radius) a meio do + // scroll. Colocando o gradiente diretamente aqui, ele ocupa sempre + // exatamente a altura atual da barra, sem fade nem parallax. + flexibleSpace: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(40), + ), + child: Container( + decoration: const BoxDecoration(gradient: kAppBarGradient), + child: Stack( + fit: StackFit.expand, + children: [ + if (hasScore) Positioned( left: 0, right: 0, - top: kToolbarHeight + 96, + top: kToolbarHeight + 50, 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, - ), - ], + 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( + 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), FadeSlideIn( delay: const Duration(milliseconds: 110), @@ -1068,34 +1046,6 @@ class _InicioTab extends StatelessWidget { ), ); } - - Future _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 @@ -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?> _createChildViaSheet( BuildContext context, String uid, diff --git a/lib/screens/hello_splash_screen.dart b/lib/screens/hello_splash_screen.dart index 3e49939..5209366 100644 --- a/lib/screens/hello_splash_screen.dart +++ b/lib/screens/hello_splash_screen.dart @@ -36,7 +36,7 @@ class _HelloSplashScreenState extends State with TickerProvid _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 500), + duration: const Duration(milliseconds: 250), ); _opacity = CurvedAnimation( diff --git a/lib/screens/video_screen.dart b/lib/screens/video_screen.dart index a19f2bf..8c808e4 100644 --- a/lib/screens/video_screen.dart +++ b/lib/screens/video_screen.dart @@ -8,6 +8,7 @@ import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import '../watched_videos_prefs.dart'; import '../colors/app_gradients.dart'; +import '../strings/home_strings.dart'; import '../strings/video_strings.dart'; import '../widgets/entrance.dart'; import '../widgets/liquid_waves_background.dart'; @@ -213,12 +214,25 @@ class VideoScreen extends StatefulWidget { class _VideoScreenState extends State { final TextEditingController _searchController = TextEditingController(); List _filteredVideos = videoList; + int? _watchedCount; @override void initState() { super.initState(); _filteredVideos = videoList; _searchController.addListener(_onSearchChanged); + _loadWatchedCount(); + } + + Future _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 @@ -302,6 +316,10 @@ class _VideoScreenState extends State { ), ), ), + if ((widget.scopeId ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 12), + _CompletedEpisodesCard(count: _watchedCount), + ], const SizedBox(height: 16), // Lista vertical (um vídeo abaixo do outro, rolando para // baixo), mas cada card em si é horizontal — miniatura à @@ -329,6 +347,7 @@ class _VideoScreenState extends State { child: _VideoButton( video: _filteredVideos[index], scopeId: widget.scopeId, + onVideoClosed: _loadWatchedCount, ), ); }, @@ -344,6 +363,66 @@ class _VideoScreenState extends State { } } +/// 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 /// em qualquer card que precise mostrar "a cara" de um episódio. class VideoThumbnail extends StatefulWidget { @@ -509,18 +588,20 @@ class _VideoThumbnailState extends State { } class _VideoButton extends StatelessWidget { - const _VideoButton({required this.video, this.scopeId}); + const _VideoButton({required this.video, this.scopeId, this.onVideoClosed}); final VideoData video; final String? scopeId; + final VoidCallback? onVideoClosed; - void _showVideoPlayer(BuildContext context, VideoData video) { + Future _showVideoPlayer(BuildContext context, VideoData video) async { if (video.youtubeId == null) { // 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. _evictAllVideoControllers(); } - showVideoPlayerDialog(context, video, scopeId: scopeId); + await showVideoPlayerDialog(context, video, scopeId: scopeId); + onVideoClosed?.call(); } @override diff --git a/lib/strings/consultorios_strings.dart b/lib/strings/consultorios_strings.dart index a739102..1227da7 100644 --- a/lib/strings/consultorios_strings.dart +++ b/lib/strings/consultorios_strings.dart @@ -6,7 +6,7 @@ class ConsultoriosStrings { static const String navConsultorios = '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 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 String callFailed(Object e) => 'Não foi possível ligar: $e'; 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 noAddressMessage = diff --git a/pubspec.yaml b/pubspec.yaml index 8cf9fb0..5e30a74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.0.0+1 +version: 1.2.0 environment: sdk: ^3.10.4