From 02dc474f97f444ff770ccb0255ff2556487ce00f Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Sun, 11 Oct 2026 16:33:10 +0200 Subject: [PATCH 01/16] feat(mobile): elevate Now secondary situations and Ongoing continuity dimensions --- mobile/lib/features/now/now_screen.dart | 55 +++++++ .../lib/features/ongoing/ongoing_screen.dart | 139 ++++++++++++++++-- 2 files changed, 179 insertions(+), 15 deletions(-) diff --git a/mobile/lib/features/now/now_screen.dart b/mobile/lib/features/now/now_screen.dart index 7dce9b005..1e25fc385 100644 --- a/mobile/lib/features/now/now_screen.dart +++ b/mobile/lib/features/now/now_screen.dart @@ -712,11 +712,24 @@ class _NowSecondarySituation extends StatelessWidget { final contextLabel = _distinctPresentationText(situation.humanContext, [ situation.meaning, ]); + final whyNow = _distinctPresentationText(situation.whyNow, [ + situation.meaning, + situation.humanContext, + ]); + final topology = topologyFor(situation); + final showTurnCue = + topology == NowTopology.waiting && situation.turnLabel != null; + final showActionCue = + topology == NowTopology.action && + situation.businessActions.isNotEmpty && + situation.responseLabel != null; + return MakoloCard( onTap: onSelect, semanticLabel: [ ?contextLabel, situation.meaning, + if (whyNow != null) whyNow, 'Ouvrir le détail', ].join('. '), child: Column( @@ -730,6 +743,48 @@ class _NowSecondarySituation extends StatelessWidget { situation.meaning, style: Theme.of(context).textTheme.titleLarge, ), + if (whyNow != null) ...[ + const SizedBox(height: MakoloSpacing.sm), + Text( + whyNow, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (showTurnCue) ...[ + const SizedBox(height: MakoloSpacing.sm), + Row( + children: [ + Icon( + Icons.hourglass_top_outlined, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.xs), + Flexible( + child: Text( + situation.turnLabel!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + if (showActionCue) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloStatus( + label: situation.responseLabel!, + tone: MakoloStatusTone.info, + icon: Icons.arrow_forward_rounded, + ), + ], if (situation.metadata.isNotEmpty) ...[ const SizedBox(height: MakoloSpacing.sm), MakoloMetadata( diff --git a/mobile/lib/features/ongoing/ongoing_screen.dart b/mobile/lib/features/ongoing/ongoing_screen.dart index 37e50eec3..ac0b8b88d 100644 --- a/mobile/lib/features/ongoing/ongoing_screen.dart +++ b/mobile/lib/features/ongoing/ongoing_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../data/local/profile_store.dart'; import '../../design/behavior_states.dart'; import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; import '../../design/makolo_theme.dart'; import '../../design/presentation_layout.dart'; import '../../design/surface_states.dart'; @@ -240,6 +241,8 @@ class _OngoingRow extends StatelessWidget { Widget build(BuildContext context) { final summary = _ongoingSummary(item.title, item.synthesis); final supporting = _supportingLine(item); + final hasBlocker = item.blocker != null; + final hasMySide = item.mySide.isNotEmpty; return Semantics( button: true, selected: selected, @@ -261,6 +264,18 @@ class _OngoingRow extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), Text(supporting, style: Theme.of(context).textTheme.bodyMedium), ], + if (hasBlocker || hasMySide) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloStatus( + label: hasBlocker ? 'Bloqué' : 'Action de votre côté', + tone: hasBlocker + ? MakoloStatusTone.warning + : MakoloStatusTone.neutral, + icon: hasBlocker + ? Icons.block_outlined + : Icons.person_outline_rounded, + ), + ], if (stale) ...[ const SizedBox(height: MakoloSpacing.sm), Text( @@ -302,7 +317,7 @@ class _OngoingFocus extends StatelessWidget { item.blocker != null || item.unknown != null; - List _detailSections() { + List _detailSections(BuildContext context) { final seen = {}; List unique(Iterable values) => [ for (final value in values) @@ -316,20 +331,104 @@ class _OngoingFocus extends StatelessWidget { ..add(_ongoingKey(item.title)) ..add(_ongoingKey(item.synthesis)); final sections = []; - void add(String title, Iterable values) { - final visible = unique(values); - if (visible.isNotEmpty) { - sections.add(_DimensionSection(title: title, values: visible)); + + // Settled — what's already done + final settledValues = unique(item.settled); + if (settledValues.isNotEmpty) { + sections.add( + _DimensionSection(title: 'Déjà réglé', values: settledValues), + ); + } + + // My side — explicit action from me + final mySideValues = unique(item.mySide); + if (mySideValues.isNotEmpty) { + sections.add( + _DimensionSection( + title: 'De votre côté', + values: mySideValues, + emphasis: true, + ), + ); + } + + // Elsewhere — things moving on the other side + final elsewhereValues = unique(item.elsewhere); + if (elsewhereValues.isNotEmpty) { + sections.add( + _DimensionSection(title: 'Ailleurs', values: elsewhereValues), + ); + } + + // Next — what follows + final nextValues = unique(item.next); + if (nextValues.isNotEmpty) { + sections.add(_DimensionSection(title: 'Ensuite', values: nextValues)); + } + + // Waiting — hourglass row + if (item.waiting != null) { + final waitingText = unique([item.waiting!]); + if (waitingText.isNotEmpty) { + sections.add( + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.hourglass_top_outlined, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'En attente', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: MakoloSpacing.xs), + Text(waitingText.first), + ], + ), + ), + ], + ), + ), + ); + } + } + + // Blocker — attention block with warning styling + if (item.blocker != null) { + final blockerText = unique([item.blocker!]); + if (blockerText.isNotEmpty) { + sections.add( + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.lg), + child: MakoloAttentionBlock( + title: 'Ce qui bloque', + body: blockerText.first, + icon: Icons.block_outlined, + ), + ), + ); + } + } + + // Unknown — to verify + if (item.unknown != null) { + final unknownText = unique([item.unknown!]); + if (unknownText.isNotEmpty) { + sections.add( + _DimensionSection(title: 'À vérifier', values: unknownText), + ); } } - add('Déjà réglé', item.settled); - add('De votre côté', item.mySide); - add('Ailleurs', item.elsewhere); - add('Ensuite', item.next); - if (item.waiting != null) add('En attente', [item.waiting!]); - if (item.blocker != null) add('Ce qui bloque', [item.blocker!]); - if (item.unknown != null) add('À vérifier', [item.unknown!]); return sections; } @@ -382,7 +481,7 @@ class _OngoingFocus extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, ), ], - ..._detailSections(), + ..._detailSections(context), if (metadata.isNotEmpty) Padding( padding: const EdgeInsets.only(top: MakoloSpacing.lg), @@ -396,9 +495,14 @@ class _OngoingFocus extends StatelessWidget { } class _DimensionSection extends StatelessWidget { - const _DimensionSection({required this.title, required this.values}); + const _DimensionSection({ + required this.title, + required this.values, + this.emphasis = false, + }); final String title; final List values; + final bool emphasis; @override Widget build(BuildContext context) => Padding( @@ -406,7 +510,12 @@ class _DimensionSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.titleLarge), + Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: emphasis ? Theme.of(context).colorScheme.primary : null, + ), + ), const SizedBox(height: MakoloSpacing.sm), for (final value in values) ...[ Text(value, style: Theme.of(context).textTheme.bodyLarge), From 2da30f1dd5a8decbe431cbcb21c41da0cae124bd Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Sun, 11 Oct 2026 16:37:13 +0200 Subject: [PATCH 02/16] feat(mobile): enrich Discovery contextual metadata and focus depth pane --- .../discovery/discovery_mature_view.dart | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/mobile/lib/features/discovery/discovery_mature_view.dart b/mobile/lib/features/discovery/discovery_mature_view.dart index e2319907f..7fdc86fc3 100644 --- a/mobile/lib/features/discovery/discovery_mature_view.dart +++ b/mobile/lib/features/discovery/discovery_mature_view.dart @@ -448,6 +448,11 @@ class _DiscoveryUnit extends StatelessWidget { MakoloMetadataItem(item.owner!, icon: Icons.business_outlined), if (item.place != null) MakoloMetadataItem(item.place!, icon: Icons.place_outlined), + if (item.distanceKm != null) + MakoloMetadataItem( + '${item.distanceKm! < 10 ? item.distanceKm!.toStringAsFixed(1) : item.distanceKm!.round()} km', + icon: Icons.near_me_outlined, + ), if (item.timing != null) MakoloMetadataItem(item.timing!, icon: Icons.schedule_outlined), if (item.price != null) @@ -620,6 +625,43 @@ class _DiscoveryFocusPane extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), Text(item.summary), ], + if (item.owner != null || + item.place != null || + item.timing != null || + item.price != null || + item.distanceKm != null) ...[ + const SizedBox(height: MakoloSpacing.md), + MakoloMetadata( + items: [ + if (item.owner != null) + MakoloMetadataItem( + item.owner!, + icon: Icons.business_outlined, + ), + if (item.place != null) + MakoloMetadataItem(item.place!, icon: Icons.place_outlined), + if (item.distanceKm != null) + MakoloMetadataItem( + '${item.distanceKm! < 10 ? item.distanceKm!.toStringAsFixed(1) : item.distanceKm!.round()} km', + icon: Icons.near_me_outlined, + ), + if (item.timing != null) + MakoloMetadataItem( + item.timing!, + icon: Icons.schedule_outlined, + ), + if (item.price != null) + MakoloMetadataItem( + item.price!, + icon: Icons.payments_outlined, + ), + ], + ), + ], + if (item.availability != null) ...[ + const SizedBox(height: MakoloSpacing.md), + MakoloStatus(label: item.availability!), + ], const SizedBox(height: MakoloSpacing.lg), FilledButton.icon( onPressed: onOpen, From caa236504160bcd15156976fb1a2ede70b127be5 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Sun, 11 Oct 2026 16:40:04 +0200 Subject: [PATCH 03/16] feat(mobile): adapt Me collective and resource icons to entity archetype --- mobile/lib/features/me/me_screen.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/me/me_screen.dart b/mobile/lib/features/me/me_screen.dart index 27ac49035..179f02322 100644 --- a/mobile/lib/features/me/me_screen.dart +++ b/mobile/lib/features/me/me_screen.dart @@ -638,12 +638,21 @@ class _MeIdentityRow extends StatelessWidget { contentPadding: EdgeInsets.zero, minVerticalPadding: MakoloSpacing.sm, onTap: onTap, - leading: const Icon(Icons.group_outlined), + leading: Icon(_identityIcon(item.destination.kind)), title: Text(item.title), subtitle: item.subtitle == null ? null : Text(item.subtitle!), trailing: const Icon(Icons.chevron_right_rounded), ); } + + IconData _identityIcon(String kind) { + return switch (kind) { + 'space' => Icons.business_outlined, + 'team' => Icons.badge_outlined, + 'group' => Icons.groups_outlined, + _ => Icons.group_outlined, + }; + } } class _MeResourceRow extends StatelessWidget { @@ -669,6 +678,8 @@ class _MeResourceRow extends StatelessWidget { return switch (kind) { 'proof' => Icons.verified_outlined, 'credential' => Icons.workspace_premium_outlined, + 'personal_asset' => Icons.folder_outlined, + 'resource_collection' => Icons.inventory_2_outlined, _ => Icons.description_outlined, }; } From db770602c9d0eda0381c21981a52aa03a9ac30b2 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:06:01 +0200 Subject: [PATCH 04/16] feat(mobile): advance Now presentation topology ownership and elevate SpaceWorkSurface cards --- mobile/lib/features/now/now_screen.dart | 33 +---- .../features/space/space_work_surface.dart | 117 +++++++++++++++--- .../contracts/now_presentation.dart | 33 +++++ 3 files changed, 133 insertions(+), 50 deletions(-) diff --git a/mobile/lib/features/now/now_screen.dart b/mobile/lib/features/now/now_screen.dart index 1e25fc385..afd5b4ee0 100644 --- a/mobile/lib/features/now/now_screen.dart +++ b/mobile/lib/features/now/now_screen.dart @@ -13,6 +13,8 @@ import '../../navigation/deep_link_resolver.dart'; import '../../navigation/destination.dart'; import '../../navigation/refresh_boundary.dart'; import '../../presentation/contracts/now_presentation.dart'; +export '../../presentation/contracts/now_presentation.dart' + show NowTopology, topologyFor; import '../../repositories/personal_repository.dart'; import '../../sync/freshness.dart'; import '../../sync/sync_engine.dart'; @@ -414,37 +416,6 @@ String? _distinctPresentationText(String? value, Iterable others) { return text; } -enum NowTopology { meaning, media, action, waiting, composition } - -NowTopology topologyFor(NowSituationPresentation situation) { - if (situation.mediaBindings.any((media) => media.canDominate)) { - return NowTopology.media; - } - if (situation.businessActions.any((action) => action.canDominate)) { - return NowTopology.action; - } - // A relation alone is not a composed Now situation: require a present - // consequence and an explicit explanation from the owning projection. - final memberIds = situation.relationMembers.map((item) => item.id).toSet(); - final hasRealRelation = situation.relations.any((relation) { - final linked = relation.memberIds.toSet().intersection(memberIds); - return relation.kind.trim().isNotEmpty && linked.length >= 2; - }); - if (memberIds.length >= 2 && - hasRealRelation && - situation.whyNow?.trim().isNotEmpty == true && - situation.consequence?.trim().isNotEmpty == true && - situation.responseType?.trim().isNotEmpty == true) { - return NowTopology.composition; - } - // Waiting is a response owned by the projection, not an inferred status. - final response = situation.responseType?.toLowerCase(); - if (response == 'wait' || response == 'monitor' || response == 'waiting') { - return NowTopology.waiting; - } - return NowTopology.meaning; -} - class _NowSemanticContent extends StatelessWidget { const _NowSemanticContent({ required this.situation, diff --git a/mobile/lib/features/space/space_work_surface.dart b/mobile/lib/features/space/space_work_surface.dart index 6641d88e3..e46c91b20 100644 --- a/mobile/lib/features/space/space_work_surface.dart +++ b/mobile/lib/features/space/space_work_surface.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../design/behavior_states.dart'; +import '../../design/makolo_components.dart'; import '../../design/makolo_theme.dart'; class SpaceWorkSurface extends StatelessWidget { @@ -74,43 +75,121 @@ class SpaceWorkSurface extends StatelessWidget { Widget _item(BuildContext context, Map item) { final title = _string(item['title']) ?? 'Élément'; - final subtitle = _subtitle(item); + final summary = _string(item['summary']); final source = _map(item['source']); + final kind = _string(item['kind']) ?? _string(source?['kind']); final capabilities = item['capabilities']; final canOpenDayOf = source?['kind'] == 'occurrence' && source?['id'] is String && capabilities is List && capabilities.contains('open_day_of'); - return ListTile( - contentPadding: EdgeInsets.zero, - title: Text(title), - subtitle: subtitle == null ? null : Text(subtitle), - trailing: canOpenDayOf ? const Icon(Icons.chevron_right_rounded) : null, - onTap: canOpenDayOf - ? () => context.push('/space/occurrences/${source!['id']}/day-of') - : null, - ); - } - String? _subtitle(Map item) { - final parts = []; - final summary = _string(item['summary']); - if (summary != null) parts.add(summary); + final metadataItems = []; final timing = _map(item['timing']); final day = _string(timing?['start_date']); final clock = _string(timing?['start_time']); if (day != null) { - parts.add(clock == null ? day : '$day · $clock'); + metadataItems.add( + MakoloMetadataItem( + clock == null ? day : '$day · $clock', + icon: Icons.schedule_outlined, + ), + ); } final until = _string(timing?['available_until']); - if (until != null) parts.add('Jusqu’au $until'); + if (until != null) { + metadataItems.add( + MakoloMetadataItem( + 'Jusqu’au $until', + icon: Icons.event_available_outlined, + ), + ); + } final itemContext = _map(item['context']); final passengerCapacity = itemContext?['passenger_capacity']; if (passengerCapacity is num) { - parts.add('${passengerCapacity.toInt()} places'); + metadataItems.add( + MakoloMetadataItem( + '${passengerCapacity.toInt()} places', + icon: Icons.airline_seat_recline_normal_outlined, + ), + ); } - return parts.isEmpty ? null : parts.join('\n'); + + final state = _string(item['state']); + + return Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.sm), + child: MakoloCard( + onTap: canOpenDayOf + ? () => context.push('/space/occurrences/${source!['id']}/day-of') + : null, + semanticLabel: title, + padding: const EdgeInsets.all(MakoloSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, right: MakoloSpacing.md), + child: Icon( + _archetypeIcon(kind), + color: Theme.of(context).colorScheme.primary, + size: 22, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + if (summary != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + summary, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + if (metadataItems.isNotEmpty) ...[ + const SizedBox(height: MakoloSpacing.sm), + MakoloMetadata(items: metadataItems), + ], + if (state != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + MakoloStatus(label: state), + ], + ], + ), + ), + if (canOpenDayOf) + Padding( + padding: const EdgeInsets.only(left: MakoloSpacing.sm, top: 2), + child: Icon( + Icons.chevron_right_rounded, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ); + } + + IconData _archetypeIcon(String? kind) { + return switch (kind) { + 'departure' || 'transport_route' => Icons.directions_bus_outlined, + 'occurrence' => Icons.schedule_outlined, + 'activity' => Icons.local_activity_outlined, + 'offer' || 'order' => Icons.shopping_bag_outlined, + 'journey' => Icons.timeline_outlined, + _ => Icons.work_outline_rounded, + }; } Widget _message(String message) => ListView( diff --git a/mobile/lib/presentation/contracts/now_presentation.dart b/mobile/lib/presentation/contracts/now_presentation.dart index 0d5ab3109..d12966657 100644 --- a/mobile/lib/presentation/contracts/now_presentation.dart +++ b/mobile/lib/presentation/contracts/now_presentation.dart @@ -180,4 +180,37 @@ class NowSituationPresentation { final String? knowledgeState; final MakoloFreshnessCue freshness; final StructuredDestination? ownerDestination; + + NowTopology get topology => topologyFor(this); +} + +enum NowTopology { meaning, media, action, waiting, composition } + +NowTopology topologyFor(NowSituationPresentation situation) { + if (situation.mediaBindings.any((media) => media.canDominate)) { + return NowTopology.media; + } + if (situation.businessActions.any((action) => action.canDominate)) { + return NowTopology.action; + } + // A relation alone is not a composed Now situation: require a present + // consequence and an explicit explanation from the owning projection. + final memberIds = situation.relationMembers.map((item) => item.id).toSet(); + final hasRealRelation = situation.relations.any((relation) { + final linked = relation.memberIds.toSet().intersection(memberIds); + return relation.kind.trim().isNotEmpty && linked.length >= 2; + }); + if (memberIds.length >= 2 && + hasRealRelation && + situation.whyNow?.trim().isNotEmpty == true && + situation.consequence?.trim().isNotEmpty == true && + situation.responseType?.trim().isNotEmpty == true) { + return NowTopology.composition; + } + // Waiting is a response owned by the projection, not an inferred status. + final response = situation.responseType?.toLowerCase(); + if (response == 'wait' || response == 'monitor' || response == 'waiting') { + return NowTopology.waiting; + } + return NowTopology.meaning; } From 6922fc524c0e289f1b4ed7ea11c0adef8aae3e34 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:10:35 +0200 Subject: [PATCH 05/16] feat(mobile): elevate History and Transverse Retrieval items with archetype recognition --- .../lib/features/continuity/history_screen.dart | 17 +++++++++++++++++ .../continuity/transverse_retrieval_screen.dart | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/mobile/lib/features/continuity/history_screen.dart b/mobile/lib/features/continuity/history_screen.dart index 1cb2099bd..318cdf17e 100644 --- a/mobile/lib/features/continuity/history_screen.dart +++ b/mobile/lib/features/continuity/history_screen.dart @@ -423,6 +423,10 @@ class _HistoryCard extends StatelessWidget { return MakoloCard( onTap: item.id == null ? null : () => onOpen(item.kind, item.id!), child: MakoloStatusMetadataAction( + leading: Icon( + _kindIcon(item.kind), + color: Theme.of(context).colorScheme.primary, + ), title: item.title, subtitle: item.occurredAt, status: item.outcome == null @@ -434,6 +438,19 @@ class _HistoryCard extends StatelessWidget { ), ); } + + IconData _kindIcon(String kind) { + return switch (kind) { + 'journey' => Icons.timeline_outlined, + 'activity' || 'occurrence' => Icons.event_outlined, + 'order' || 'offer' => Icons.shopping_bag_outlined, + 'access' => Icons.badge_outlined, + 'payment' => Icons.payments_outlined, + 'form' || 'document' => Icons.description_outlined, + 'message' || 'conversation' => Icons.chat_bubble_outline_rounded, + _ => Icons.history_rounded, + }; + } } class _HistoryView { diff --git a/mobile/lib/features/continuity/transverse_retrieval_screen.dart b/mobile/lib/features/continuity/transverse_retrieval_screen.dart index 9817e1f44..034cae9d8 100644 --- a/mobile/lib/features/continuity/transverse_retrieval_screen.dart +++ b/mobile/lib/features/continuity/transverse_retrieval_screen.dart @@ -813,6 +813,10 @@ class _TransverseRetrievalScreenState extends State { for (final index in group.value) ListTile( key: ValueKey('retrieval-$index'), + leading: Icon( + _retrievalIcon(rows[index]['kind']?.toString()), + color: Theme.of(context).colorScheme.primary, + ), title: Text( rows[index]['title']?.toString() ?? 'Élément', ), @@ -879,4 +883,17 @@ class _TransverseRetrievalScreenState extends State { ), ); } + + IconData _retrievalIcon(String? kind) { + return switch (kind) { + 'journey' => Icons.timeline_outlined, + 'access' => Icons.badge_outlined, + 'personal_asset' || 'document' => Icons.folder_outlined, + 'group' || 'team' => Icons.group_outlined, + 'commerce_order' || 'order' => Icons.shopping_bag_outlined, + 'activity' || 'occurrence' => Icons.event_outlined, + 'space' => Icons.business_outlined, + _ => Icons.search_rounded, + }; + } } From a1e85ead4bf078b15f2387bb577679fc3f046f8f Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:12:22 +0200 Subject: [PATCH 06/16] feat(mobile): elevate Access collection cards with structured status tone and holder presentation --- .../access/access_collection_screen.dart | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/access/access_collection_screen.dart b/mobile/lib/features/access/access_collection_screen.dart index 4b47b6528..8f439ae35 100644 --- a/mobile/lib/features/access/access_collection_screen.dart +++ b/mobile/lib/features/access/access_collection_screen.dart @@ -242,40 +242,84 @@ class _AccessCollectionCard extends StatelessWidget { final _AccessCollectionItem item; final VoidCallback onOpen; + MakoloStatusTone _stateTone(String state) { + return switch (state.toLowerCase()) { + 'valide' || 'valid' => MakoloStatusTone.success, + 'en attente' || 'pending' => MakoloStatusTone.warning, + 'annulé' || 'annule' || 'cancelled' => MakoloStatusTone.error, + _ => MakoloStatusTone.neutral, + }; + } + @override Widget build(BuildContext context) { - final details = [ + final contextLine = [ if (item.context != null) item.context!, - item.stateLabel, if (item.validity != null) item.validity!, - if (item.holder != null) 'Pour ${item.holder!}', - ]; + ].join(' · '); return MakoloCard( onTap: onOpen, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.badge_outlined), + Icon( + Icons.badge_outlined, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: MakoloSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.title, - style: Theme.of(context).textTheme.titleMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + item.title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(width: MakoloSpacing.sm), + MakoloStatus( + label: item.stateLabel, + tone: _stateTone(item.stateLabel), + ), + ], ), - const SizedBox(height: MakoloSpacing.xs), - Text( - details.join(' · '), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + if (contextLine.isNotEmpty) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + contextLine, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), + ], + if (item.holder != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Row( + children: [ + Icon( + Icons.person_outline_rounded, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: MakoloSpacing.xs), + Text( + 'Pour ${item.holder!}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], ], ), ), + const SizedBox(width: MakoloSpacing.sm), const Icon(Icons.chevron_right_rounded), ], ), From fd06c3c7701e31c0eb7cbc1c28b31e85b144d959 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:14:45 +0200 Subject: [PATCH 07/16] feat(mobile): make Me passport card tappable and item-aware --- mobile/lib/features/me/me_screen.dart | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/me/me_screen.dart b/mobile/lib/features/me/me_screen.dart index 179f02322..4c79b71b7 100644 --- a/mobile/lib/features/me/me_screen.dart +++ b/mobile/lib/features/me/me_screen.dart @@ -467,7 +467,16 @@ class _MeTerritory extends StatelessWidget { return _empty(context, 'Aucun Passeport disponible ici pour le moment.'); } + final firstItem = territory.items.isNotEmpty ? territory.items.first : null; + final title = firstItem?.title ?? 'Disponible'; + final subtitle = + firstItem?.subtitle ?? + 'Une vue de ce qui peut vous représenter selon le contexte.'; + return MakoloCard( + onTap: firstItem == null || onSelectItem == null + ? null + : () => onSelectItem!(firstItem), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -480,19 +489,14 @@ class _MeTerritory extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Disponible', - style: Theme.of(context).textTheme.titleMedium, - ), + Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: MakoloSpacing.xs), - Text( - 'Une vue de ce qui peut vous ' - 'représenter selon le contexte.', - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), ], ), ), + if (firstItem != null && onSelectItem != null) + const Icon(Icons.chevron_right_rounded), ], ), ); From d370cc6745efeb38e4f35cce693d35c7f10c7ad3 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:16:22 +0200 Subject: [PATCH 08/16] feat(mobile): render deduplicated consequence in Now primary situation --- mobile/lib/features/now/now_screen.dart | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/now/now_screen.dart b/mobile/lib/features/now/now_screen.dart index afd5b4ee0..ddc4c7430 100644 --- a/mobile/lib/features/now/now_screen.dart +++ b/mobile/lib/features/now/now_screen.dart @@ -603,11 +603,21 @@ class _NowPrimarySituation extends StatelessWidget { situation.meaning, situation.humanContext, ]); + final consequence = _distinctPresentationText(situation.consequence, [ + situation.meaning, + situation.humanContext, + situation.whyNow, + ]); return Semantics( container: true, button: true, selected: selected, - label: [?contextLabel, situation.meaning, ?whyNow].join('. '), + label: [ + ?contextLabel, + situation.meaning, + ?consequence, + ?whyNow, + ].join('. '), child: InkWell( onTap: onSelect, borderRadius: BorderRadius.circular(MakoloRadii.card), @@ -629,6 +639,16 @@ class _NowPrimarySituation extends StatelessWidget { situation.meaning, style: Theme.of(context).textTheme.headlineSmall, ), + if (consequence != null) ...[ + const SizedBox(height: MakoloSpacing.xs), + Text( + consequence, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], if (whyNow != null) ...[ const SizedBox(height: MakoloSpacing.md), Text( From 0366d81203ee06937427f055d4d31f905172f849 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 10:18:35 +0200 Subject: [PATCH 09/16] feat(mobile): add bookmark affordance to Discovery cards and focus depth pane --- .../discovery/discovery_mature_view.dart | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/discovery/discovery_mature_view.dart b/mobile/lib/features/discovery/discovery_mature_view.dart index 7fdc86fc3..13d64d6e6 100644 --- a/mobile/lib/features/discovery/discovery_mature_view.dart +++ b/mobile/lib/features/discovery/discovery_mature_view.dart @@ -512,9 +512,32 @@ class _DiscoveryUnit extends StatelessWidget { const SizedBox(height: MakoloSpacing.md), MakoloMetadata(items: metadata), ], - if (item.availability != null) ...[ + if (item.availability != null || + item.canSave || + item.canUnsave || + item.savedState != null) ...[ const SizedBox(height: MakoloSpacing.md), - MakoloStatus(label: item.availability!), + Row( + children: [ + if (item.availability != null) + MakoloStatus(label: item.availability!), + const Spacer(), + if (item.canSave || + item.canUnsave || + item.savedState != null) + Icon( + item.savedState == 'saved' + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + size: 20, + color: item.savedState == 'saved' + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ], + ), ], if (onSelect != null) ...[ const SizedBox(height: MakoloSpacing.md), @@ -658,9 +681,28 @@ class _DiscoveryFocusPane extends StatelessWidget { ], ), ], - if (item.availability != null) ...[ + if (item.availability != null || + item.canSave || + item.canUnsave || + item.savedState != null) ...[ const SizedBox(height: MakoloSpacing.md), - MakoloStatus(label: item.availability!), + Row( + children: [ + if (item.availability != null) + MakoloStatus(label: item.availability!), + const Spacer(), + if (item.canSave || item.canUnsave || item.savedState != null) + Icon( + item.savedState == 'saved' + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + size: 22, + color: item.savedState == 'saved' + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), ], const SizedBox(height: MakoloSpacing.lg), FilledButton.icon( From 1937a6c1b18f87e610294c0169936f71f8272d8d Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 11:43:08 +0200 Subject: [PATCH 10/16] style(mobile): apply canonical repository Dart formatter --- mobile/lib/app/makolo_app.dart | 11 ++- mobile/lib/app/runtime/runtime_builder.dart | 5 +- mobile/lib/data/files/media_cache.dart | 11 +-- mobile/lib/design/behavior_primitives.dart | 18 ++-- mobile/lib/design/makolo_components.dart | 6 +- mobile/lib/design/makolo_patterns.dart | 5 +- mobile/lib/design/presentation_media.dart | 5 +- mobile/lib/design/surface_states.dart | 3 +- .../access/access_credential_screen.dart | 3 +- .../features/access/access_detail_screen.dart | 6 +- mobile/lib/features/access/access_routes.dart | 3 +- .../features/auth/auth_error_messages.dart | 6 +- .../features/auth/forgot_password_screen.dart | 3 +- mobile/lib/features/auth/login_screen.dart | 3 +- mobile/lib/features/auth/signup_screen.dart | 3 +- .../continuity/conversation_screens.dart | 21 +++-- .../features/continuity/objective_screen.dart | 3 +- .../interoperability/connections_screen.dart | 3 +- .../journey/journey_detail_screen.dart | 6 +- mobile/lib/features/live/live_screen.dart | 3 +- mobile/lib/features/mark/mark_repository.dart | 6 +- mobile/lib/features/mark/mark_screen.dart | 5 +- mobile/lib/features/me/me_routes.dart | 3 +- mobile/lib/features/now/now_media_viewer.dart | 6 +- .../features/onboarding/onboarding_flow.dart | 9 +- .../features/personal/projection_screen.dart | 3 +- .../preparation_resources_screen.dart | 6 +- .../requirement_detail_screen.dart | 3 +- .../questionnaire_form_screen.dart | 6 +- .../questionnaires/questionnaire_routes.dart | 3 +- .../resources/resource_detail_screen.dart | 6 +- .../features/resources/resources_screen.dart | 5 +- mobile/lib/navigation/avatar_sheet.dart | 12 +-- mobile/lib/navigation/shell_header.dart | 6 +- .../notifications/notification_screens.dart | 6 +- mobile/lib/platform/maps/makolo_map_view.dart | 11 +-- .../notifications/push_signal_processor.dart | 5 +- .../presentation/mps/mps_native_renderer.dart | 21 +++-- .../runtime/firebase_runtime_bootstrap.dart | 5 +- .../lib/runtime/observability_bootstrap.dart | 7 +- mobile/lib/sync/outbox/outbox_processor.dart | 10 +-- mobile/lib/sync/sync_source.dart | 9 +- .../actor_context_stabilization_test.dart | 63 +++++++------- mobile/test/api_client_test.dart | 9 +- mobile/test/app_shell_test.dart | 5 +- .../ar_access_real_world_action_test.dart | 35 ++++---- .../test/discovery_shell_contract_test.dart | 15 ++-- mobile/test/file_media_core_test.dart | 5 +- .../test/file_transfer_continuity_test.dart | 5 +- mobile/test/mps_repository_test.dart | 83 ++++++++++--------- mobile/test/native_launch_contract_test.dart | 20 +++-- .../test/now_cross_surface_contract_test.dart | 8 +- mobile/test/now_gallery_scenarios_test.dart | 12 +-- mobile/test/now_media_in_app_test.dart | 6 +- mobile/test/now_media_viewer_test.dart | 18 ++-- mobile/test/share_ingress_test.dart | 5 +- .../test/shell_recovery_persistence_test.dart | 17 ++-- mobile/test/space_actor_shell_test.dart | 5 +- mobile/test/sync_engine_test.dart | 5 +- mobile/test/workmanager_scheduler_test.dart | 34 ++++---- 60 files changed, 355 insertions(+), 275 deletions(-) diff --git a/mobile/lib/app/makolo_app.dart b/mobile/lib/app/makolo_app.dart index e2d7f8959..f414bdab4 100644 --- a/mobile/lib/app/makolo_app.dart +++ b/mobile/lib/app/makolo_app.dart @@ -53,8 +53,10 @@ class MakoloApp extends ConsumerWidget { home: MakoloSystemUi( child: Scaffold( body: MakoloErrorState( - message: 'Makolo n’a pas pu ouvrir les données locales de cet appareil.', - preservedMessage: 'Aucune donnée locale n’a été supprimée. Vous pouvez réessayer.', + message: + 'Makolo n’a pas pu ouvrir les données locales de cet appareil.', + preservedMessage: + 'Aucune donnée locale n’a été supprimée. Vous pouvez réessayer.', onRetry: () => ref.invalidate(appRuntimeProvider), ), ), @@ -105,8 +107,9 @@ class MakoloApp extends ConsumerWidget { if (preferences.reduceMotion) { launched = MediaQuery( - data: MediaQuery.of(context) - .copyWith(disableAnimations: true), + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: true), child: launched, ); } diff --git a/mobile/lib/app/runtime/runtime_builder.dart b/mobile/lib/app/runtime/runtime_builder.dart index 221e4cef2..da85b2533 100644 --- a/mobile/lib/app/runtime/runtime_builder.dart +++ b/mobile/lib/app/runtime/runtime_builder.dart @@ -27,9 +27,8 @@ import '../session_recovery.dart'; import 'actor_context_controller.dart'; import 'app_runtime.dart'; -typedef LocationCapabilityFactory = LocationCapability Function( - MakoloRuntimeConfig config, -); +typedef LocationCapabilityFactory = + LocationCapability Function(MakoloRuntimeConfig config); Future buildAppRuntime({ required TokenStore tokens, diff --git a/mobile/lib/data/files/media_cache.dart b/mobile/lib/data/files/media_cache.dart index 868ede553..e7ea6edbf 100644 --- a/mobile/lib/data/files/media_cache.dart +++ b/mobile/lib/data/files/media_cache.dart @@ -3,11 +3,12 @@ import 'dart:io'; import '../../network/makolo_api_client.dart'; import 'profile_paths.dart'; -typedef MediaDownloader = Future Function( - String destinationPath, - MakoloCancelHandle cancel, - TransferProgress? onProgress, -); +typedef MediaDownloader = + Future Function( + String destinationPath, + MakoloCancelHandle cancel, + TransferProgress? onProgress, + ); class ProfileMediaCache { ProfileMediaCache({required this.profileId, required this.directory}); diff --git a/mobile/lib/design/behavior_primitives.dart b/mobile/lib/design/behavior_primitives.dart index 6089fec9b..56f3b7f13 100644 --- a/mobile/lib/design/behavior_primitives.dart +++ b/mobile/lib/design/behavior_primitives.dart @@ -14,8 +14,9 @@ class MakoloSkeleton extends StatelessWidget { Widget build(BuildContext context) { final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; - final color = Theme.of(context).colorScheme.surfaceContainerHighest - .withValues(alpha: 0.7); + final color = Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.7); return Semantics( label: 'Chargement du contenu', @@ -161,8 +162,9 @@ class MakoloNotice extends StatelessWidget { borderRadius: BorderRadius.circular(MakoloRadii.medium), boxShadow: [ BoxShadow( - color: Theme.of(context).colorScheme.shadow - .withValues(alpha: 0.06), + color: Theme.of( + context, + ).colorScheme.shadow.withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 6), ), @@ -176,8 +178,9 @@ class MakoloNotice extends StatelessWidget { Expanded( child: Text( message, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), ), if (actionLabel != null && onAction != null) ...[ @@ -467,7 +470,8 @@ class PermissionExplainer extends StatelessWidget { if (denied) ...[ const SizedBox(height: MakoloSpacing.md), Text( - deniedMessage ?? 'Cette autorisation est refusée. Vous pouvez continuer sans cette fonction et la réactiver plus tard si nécessaire.', + deniedMessage ?? + 'Cette autorisation est refusée. Vous pouvez continuer sans cette fonction et la réactiver plus tard si nécessaire.', ), ], const SizedBox(height: MakoloSpacing.lg), diff --git a/mobile/lib/design/makolo_components.dart b/mobile/lib/design/makolo_components.dart index de5a4f55c..62d56238c 100644 --- a/mobile/lib/design/makolo_components.dart +++ b/mobile/lib/design/makolo_components.dart @@ -56,8 +56,10 @@ class MakoloStatus extends StatelessWidget { Flexible( child: Text( label, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: accent, fontWeight: FontWeight.w700), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: accent, + fontWeight: FontWeight.w700, + ), ), ), ], diff --git a/mobile/lib/design/makolo_patterns.dart b/mobile/lib/design/makolo_patterns.dart index 55d7f924c..5eedbf598 100644 --- a/mobile/lib/design/makolo_patterns.dart +++ b/mobile/lib/design/makolo_patterns.dart @@ -317,8 +317,9 @@ class MakoloDetailHeader extends StatelessWidget { if (eyebrow != null) ...[ Text( eyebrow!, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: Theme.of(context).colorScheme.primary), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), ), const SizedBox(height: MakoloSpacing.xs), ], diff --git a/mobile/lib/design/presentation_media.dart b/mobile/lib/design/presentation_media.dart index 523dacb39..f0641457f 100644 --- a/mobile/lib/design/presentation_media.dart +++ b/mobile/lib/design/presentation_media.dart @@ -73,8 +73,9 @@ class MakoloMediaPlaceholder extends StatelessWidget { Text( label!, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: foreground), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foreground), ), ], ], diff --git a/mobile/lib/design/surface_states.dart b/mobile/lib/design/surface_states.dart index 41e7170df..59a642556 100644 --- a/mobile/lib/design/surface_states.dart +++ b/mobile/lib/design/surface_states.dart @@ -202,7 +202,8 @@ class MakoloFreshnessNotice extends StatelessWidget { 'Une mise à jour est recommandée lorsque la source est joignable.', MakoloFreshnessCue.revalidationRequired => 'Une vérification distante est nécessaire avant l’action.', - MakoloFreshnessCue.expired => 'Cette observation a expiré. Une revalidation distante est nécessaire avant de continuer.', + MakoloFreshnessCue.expired => + 'Cette observation a expiré. Une revalidation distante est nécessaire avant de continuer.', }; } diff --git a/mobile/lib/features/access/access_credential_screen.dart b/mobile/lib/features/access/access_credential_screen.dart index 440872ffe..7863e9558 100644 --- a/mobile/lib/features/access/access_credential_screen.dart +++ b/mobile/lib/features/access/access_credential_screen.dart @@ -122,7 +122,8 @@ class _AccessCredentialScreenState extends State (final value?, _, _) when value.credentialType != 'qr' => const MakoloEmptyState( title: 'Représentation non prise en charge', - body: 'Cette représentation d’accès ne peut pas encore être affichée ici.', + body: + 'Cette représentation d’accès ne peut pas encore être affichée ici.', icon: Icons.lock_outline_rounded, ), (final value?, _, _) => _CredentialQr(credential: value), diff --git a/mobile/lib/features/access/access_detail_screen.dart b/mobile/lib/features/access/access_detail_screen.dart index 9bed0f206..abf8ddca9 100644 --- a/mobile/lib/features/access/access_detail_screen.dart +++ b/mobile/lib/features/access/access_detail_screen.dart @@ -125,7 +125,8 @@ class _AccessDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de l’accès…', ), - blockingErrorMessage: 'Cet accès n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: + 'Cet accès n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _AccessContent( presentation: presentation, @@ -210,7 +211,8 @@ class _AccessContent extends StatelessWidget { ), child: MakoloAttentionBlock( title: 'Action réelle', - body: 'Les informations utiles pour le moment venu sont disponibles.', + body: + 'Les informations utiles pour le moment venu sont disponibles.', action: FilledButton.icon( onPressed: () => onOpenDayOf(presentation.dayOf!), icon: const Icon(Icons.directions_walk_rounded), diff --git a/mobile/lib/features/access/access_routes.dart b/mobile/lib/features/access/access_routes.dart index f2018df22..57b6b8cc3 100644 --- a/mobile/lib/features/access/access_routes.dart +++ b/mobile/lib/features/access/access_routes.dart @@ -83,7 +83,8 @@ List accessRoutes(AppRuntime runtime) => [ if (repository == null || credentialPath == null) { return const MakoloSecondaryScreen( title: 'QR d’accès', - message: 'Rouvrez ce QR depuis le Jour J afin de revalider son lien propriétaire.', + message: + 'Rouvrez ce QR depuis le Jour J afin de revalider son lien propriétaire.', ); } return AccessCredentialScreen( diff --git a/mobile/lib/features/auth/auth_error_messages.dart b/mobile/lib/features/auth/auth_error_messages.dart index bdd5cae0d..0a9ff382e 100644 --- a/mobile/lib/features/auth/auth_error_messages.dart +++ b/mobile/lib/features/auth/auth_error_messages.dart @@ -42,7 +42,8 @@ String signupErrorMessage(Object error) { if (error is! MakoloApiError) { return authErrorMessage( error, - fallback: 'Création du compte impossible pour le moment. Réessayez dans un instant.', + fallback: + 'Création du compte impossible pour le moment. Réessayez dans un instant.', ); } @@ -102,7 +103,8 @@ Future resolvedSignupErrorMessage( if (error is MakoloApiError) return signupErrorMessage(error); return resolvedAuthErrorMessage( error, - fallback: 'Création du compte impossible pour le moment. Réessayez dans un instant.', + fallback: + 'Création du compte impossible pour le moment. Réessayez dans un instant.', offlineProbe: offlineProbe, ); } diff --git a/mobile/lib/features/auth/forgot_password_screen.dart b/mobile/lib/features/auth/forgot_password_screen.dart index 86ef9565b..94ec411a4 100644 --- a/mobile/lib/features/auth/forgot_password_screen.dart +++ b/mobile/lib/features/auth/forgot_password_screen.dart @@ -63,7 +63,8 @@ class _ForgotPasswordDialogState extends State<_ForgotPasswordDialog> { final api = widget.runtime.api; if (api == null) { setState(() { - _error = 'La récupération est indisponible sur cette installation pour le moment.'; + _error = + 'La récupération est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart index 01f3f8ba4..a713b37b5 100644 --- a/mobile/lib/features/auth/login_screen.dart +++ b/mobile/lib/features/auth/login_screen.dart @@ -131,7 +131,8 @@ class _LoginScreenState extends ConsumerState { final api = widget.runtime.api; if (api == null) { setState(() { - _error = 'La connexion est indisponible sur cette installation pour le moment.'; + _error = + 'La connexion est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/auth/signup_screen.dart b/mobile/lib/features/auth/signup_screen.dart index 86cb11633..0751f5d1b 100644 --- a/mobile/lib/features/auth/signup_screen.dart +++ b/mobile/lib/features/auth/signup_screen.dart @@ -303,7 +303,8 @@ class _SignupScreenState extends State { final api = widget.runtime.api; if (api == null) { setState(() { - _error = 'La création de compte est indisponible sur cette installation pour le moment.'; + _error = + 'La création de compte est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/continuity/conversation_screens.dart b/mobile/lib/features/continuity/conversation_screens.dart index 23825ca95..e73f8ba9b 100644 --- a/mobile/lib/features/continuity/conversation_screens.dart +++ b/mobile/lib/features/continuity/conversation_screens.dart @@ -571,7 +571,8 @@ class _ConversationDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de la conversation…', ), - blockingErrorMessage: 'Cette conversation n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: + 'Cette conversation n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: ListView( key: const Key('conversation-detail-content'), @@ -616,7 +617,8 @@ class _ConversationDetailScreenState extends State { if (detail.essential.isNotEmpty) MakoloSection( title: 'Essentiel', - description: 'Les décisions et résultats actuels restent lisibles sans relire tout le passé.', + description: + 'Les décisions et résultats actuels restent lisibles sans relire tout le passé.', child: Column( children: [ for ( @@ -724,7 +726,8 @@ class _PointCardState extends State<_PointCard> { await widget.repository.refreshDetail(widget.conversationId); if (!mounted) return; setState( - () => _feedback = 'Ce point a changé ou la réponse n’a pas été confirmée. Votre ancienne saisie n’a pas été déclarée envoyée.', + () => _feedback = + 'Ce point a changé ou la réponse n’a pas été confirmée. Votre ancienne saisie n’a pas été déclarée envoyée.', ); } finally { if (mounted) setState(() => _busy = false); @@ -1044,12 +1047,12 @@ class _ConversationDetail { _string(payload['lifecycle']), ), contextLabel: _string(context['label']), - points: _maps(payload['points']) - .map(_ConversationPoint.fromMap) - .toList(growable: false), - essential: _maps(payload['essential']) - .map(_ConversationPoint.fromMap) - .toList(growable: false), + points: _maps( + payload['points'], + ).map(_ConversationPoint.fromMap).toList(growable: false), + essential: _maps( + payload['essential'], + ).map(_ConversationPoint.fromMap).toList(growable: false), personalState: _ConversationPersonalState.fromMap( _map(payload['personal_state']), ), diff --git a/mobile/lib/features/continuity/objective_screen.dart b/mobile/lib/features/continuity/objective_screen.dart index 3d71519e3..9b76d9f8b 100644 --- a/mobile/lib/features/continuity/objective_screen.dart +++ b/mobile/lib/features/continuity/objective_screen.dart @@ -142,7 +142,8 @@ class _ObjectiveDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement…', ), - blockingErrorMessage: 'Ce contenu n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: + 'Ce contenu n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: widget.kind == ObjectiveDepth.dossier ? _DossierContent( diff --git a/mobile/lib/features/interoperability/connections_screen.dart b/mobile/lib/features/interoperability/connections_screen.dart index 176c92f83..42bcb704e 100644 --- a/mobile/lib/features/interoperability/connections_screen.dart +++ b/mobile/lib/features/interoperability/connections_screen.dart @@ -289,7 +289,8 @@ class _ProfileConnectionsScreenState extends State { height: 520, child: MakoloEmptyState( title: 'Connexions indisponibles hors ligne', - body: 'Aucune copie locale n’est encore disponible. Le reste de Makolo continue de fonctionner.', + body: + 'Aucune copie locale n’est encore disponible. Le reste de Makolo continue de fonctionner.', ), ), ], diff --git a/mobile/lib/features/journey/journey_detail_screen.dart b/mobile/lib/features/journey/journey_detail_screen.dart index 2a4030652..4a88c17e2 100644 --- a/mobile/lib/features/journey/journey_detail_screen.dart +++ b/mobile/lib/features/journey/journey_detail_screen.dart @@ -166,7 +166,8 @@ class _JourneyDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de la démarche…', ), - blockingErrorMessage: 'Cette démarche n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: + 'Cette démarche n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _JourneyContent( presentation: presentation, @@ -293,7 +294,8 @@ class _JourneyContent extends StatelessWidget { onTap: () => onOpenResources(presentation.resourcesLink!), child: const MakoloStatusMetadataAction( title: 'Ressources de préparation', - subtitle: 'Consultez ce que le propriétaire de la démarche a rendu disponible.', + subtitle: + 'Consultez ce que le propriétaire de la démarche a rendu disponible.', action: Icon(Icons.chevron_right_rounded), ), ), diff --git a/mobile/lib/features/live/live_screen.dart b/mobile/lib/features/live/live_screen.dart index c51a1c529..2d405531f 100644 --- a/mobile/lib/features/live/live_screen.dart +++ b/mobile/lib/features/live/live_screen.dart @@ -186,7 +186,8 @@ class _LiveContent extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), const MakoloAttentionBlock( title: 'Dernière information connue', - body: 'La connexion doit être vérifiée pour confirmer la situation actuelle.', + body: + 'La connexion doit être vérifiée pour confirmer la situation actuelle.', icon: Icons.schedule_rounded, ), ], diff --git a/mobile/lib/features/mark/mark_repository.dart b/mobile/lib/features/mark/mark_repository.dart index b212ad47c..7d6e06468 100644 --- a/mobile/lib/features/mark/mark_repository.dart +++ b/mobile/lib/features/mark/mark_repository.dart @@ -106,7 +106,8 @@ class MarkRepository { if (api == null) { return const MarkResult( state: 'offline_draft', - message: 'Enregistré sur cet appareil. L’action sera possible lorsque Makolo pourra revalider le contexte.', + message: + 'Enregistré sur cet appareil. L’action sera possible lorsque Makolo pourra revalider le contexte.', offline: true, ); } @@ -154,7 +155,8 @@ class MarkRepository { } on MakoloTransportError { return const MarkResult( state: 'offline_draft', - message: 'Enregistré sur cet appareil. Makolo n’a pas pu revalider le contexte à distance.', + message: + 'Enregistré sur cet appareil. Makolo n’a pas pu revalider le contexte à distance.', offline: true, ); } on MakoloApiError catch (error) { diff --git a/mobile/lib/features/mark/mark_screen.dart b/mobile/lib/features/mark/mark_screen.dart index 31d67f3a7..fe95278f7 100644 --- a/mobile/lib/features/mark/mark_screen.dart +++ b/mobile/lib/features/mark/mark_screen.dart @@ -212,8 +212,9 @@ class _MarkScreenState extends State { void _show(String message) { if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } @override diff --git a/mobile/lib/features/me/me_routes.dart b/mobile/lib/features/me/me_routes.dart index 75844ef16..ed0cf8f33 100644 --- a/mobile/lib/features/me/me_routes.dart +++ b/mobile/lib/features/me/me_routes.dart @@ -54,7 +54,8 @@ StatefulShellBranch meBranch(AppRuntime runtime) => StatefulShellBranch( if (repository == null || personal == null) { return const MakoloSecondaryScreen( title: 'Ressource', - message: 'Cette ressource n’est pas disponible sur cet appareil.', + message: + 'Cette ressource n’est pas disponible sur cet appareil.', ); } return ResourceDetailScreen( diff --git a/mobile/lib/features/now/now_media_viewer.dart b/mobile/lib/features/now/now_media_viewer.dart index f6e2b64e0..120aa1382 100644 --- a/mobile/lib/features/now/now_media_viewer.dart +++ b/mobile/lib/features/now/now_media_viewer.dart @@ -55,7 +55,8 @@ String? nowAuthorizedOriginalDownloadPath(NowMediaBindingPresentation binding) { } String nowOriginalDocumentExtension(NowMediaBindingPresentation media) { - return media.mimeType == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + return media.mimeType == + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ? 'docx' : 'txt'; } @@ -170,7 +171,8 @@ class _NowMediaViewerState extends State { if (file != null && await file.exists()) await file.delete(); if (mounted) { setState(() { - _error = 'Impossible de charger ce média. Vérifiez votre connexion et vos droits.'; + _error = + 'Impossible de charger ce média. Vérifiez votre connexion et vos droits.'; _loading = false; }); } diff --git a/mobile/lib/features/onboarding/onboarding_flow.dart b/mobile/lib/features/onboarding/onboarding_flow.dart index 7a1fcf1d8..811214eed 100644 --- a/mobile/lib/features/onboarding/onboarding_flow.dart +++ b/mobile/lib/features/onboarding/onboarding_flow.dart @@ -29,17 +29,20 @@ class _OnboardingFlowState extends State { static const _pages = <({String title, String body, IconData icon})>[ ( title: 'Découvrez ce qui compte vraiment.', - body: 'Explorez des possibilités réelles sans transformer Makolo en simple fil à parcourir.', + body: + 'Explorez des possibilités réelles sans transformer Makolo en simple fil à parcourir.', icon: Icons.explore_outlined, ), ( title: 'Préparez ce qui peut l’être.', - body: 'Rassemblez le contexte utile avant le moment d’agir et voyez clairement ce qui reste à faire.', + body: + 'Rassemblez le contexte utile avant le moment d’agir et voyez clairement ce qui reste à faire.', icon: Icons.inventory_2_outlined, ), ( title: 'Avancez dans l’action réelle.', - body: 'Retrouvez vos démarches, vos prochaines actions et la continuité nécessaire pour aller jusqu’au bout.', + body: + 'Retrouvez vos démarches, vos prochaines actions et la continuité nécessaire pour aller jusqu’au bout.', icon: Icons.arrow_forward_rounded, ), ]; diff --git a/mobile/lib/features/personal/projection_screen.dart b/mobile/lib/features/personal/projection_screen.dart index ce5f75d05..9a3beca26 100644 --- a/mobile/lib/features/personal/projection_screen.dart +++ b/mobile/lib/features/personal/projection_screen.dart @@ -74,7 +74,8 @@ class ProjectionScreen extends StatelessWidget { if (projection == null) { return const MakoloEmptyState( title: 'Pas encore disponible sur cet appareil', - body: 'Une première connexion est nécessaire pour rendre ce contenu disponible ici.', + body: + 'Une première connexion est nécessaire pour rendre ce contenu disponible ici.', icon: Icons.cloud_off_outlined, ); } diff --git a/mobile/lib/features/preparation/preparation_resources_screen.dart b/mobile/lib/features/preparation/preparation_resources_screen.dart index d3876cd06..7543c4639 100644 --- a/mobile/lib/features/preparation/preparation_resources_screen.dart +++ b/mobile/lib/features/preparation/preparation_resources_screen.dart @@ -195,12 +195,14 @@ class _PreparationResourcesScreenState state: surface, empty: const MakoloEmptyState( title: 'Rien à préparer ici', - body: 'Aucun document ou instruction partagé n’est disponible pour cette démarche.', + body: + 'Aucun document ou instruction partagé n’est disponible pour cette démarche.', ), initialLoading: const MakoloLoadingState( label: 'Chargement des ressources de préparation…', ), - blockingErrorMessage: 'Ces ressources ne sont pas disponibles dans votre contexte actuel.', + blockingErrorMessage: + 'Ces ressources ne sont pas disponibles dans votre contexte actuel.', onRetry: _refresh, content: ListView.separated( key: const Key('preparation-resources-content'), diff --git a/mobile/lib/features/preparation/requirement_detail_screen.dart b/mobile/lib/features/preparation/requirement_detail_screen.dart index 6a123e51c..2339f8614 100644 --- a/mobile/lib/features/preparation/requirement_detail_screen.dart +++ b/mobile/lib/features/preparation/requirement_detail_screen.dart @@ -147,7 +147,8 @@ class _RequirementDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de l’élément nécessaire…', ), - blockingErrorMessage: 'Cet élément n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: + 'Cet élément n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _RequirementContent( presentation: presentation, diff --git a/mobile/lib/features/questionnaires/questionnaire_form_screen.dart b/mobile/lib/features/questionnaires/questionnaire_form_screen.dart index 2952a380e..82496589c 100644 --- a/mobile/lib/features/questionnaires/questionnaire_form_screen.dart +++ b/mobile/lib/features/questionnaires/questionnaire_form_screen.dart @@ -242,8 +242,10 @@ class _QuestionnaireFormScreenState extends State { label: 'Chargement du formulaire…', ) : MakoloErrorState( - message: 'Ce formulaire n’est pas disponible sur cet appareil.', - preservedMessage: 'Votre brouillon local, s’il existe, est conservé.', + message: + 'Ce formulaire n’est pas disponible sur cet appareil.', + preservedMessage: + 'Votre brouillon local, s’il existe, est conservé.', onRetry: _refresh, )) : _FormContent( diff --git a/mobile/lib/features/questionnaires/questionnaire_routes.dart b/mobile/lib/features/questionnaires/questionnaire_routes.dart index 0d17e0a7e..0d625ab84 100644 --- a/mobile/lib/features/questionnaires/questionnaire_routes.dart +++ b/mobile/lib/features/questionnaires/questionnaire_routes.dart @@ -26,7 +26,8 @@ List questionnaireRoutes(AppRuntime runtime) => [ form.detailLink.isEmpty) { return const MakoloSecondaryScreen( title: 'Formulaire', - message: 'Rouvrez ce formulaire depuis la démarche pour reprendre avec les liens du propriétaire.', + message: + 'Rouvrez ce formulaire depuis la démarche pour reprendre avec les liens du propriétaire.', ); } return QuestionnaireFormScreen( diff --git a/mobile/lib/features/resources/resource_detail_screen.dart b/mobile/lib/features/resources/resource_detail_screen.dart index ff7ee5338..7a00e49c8 100644 --- a/mobile/lib/features/resources/resource_detail_screen.dart +++ b/mobile/lib/features/resources/resource_detail_screen.dart @@ -252,9 +252,9 @@ class _ResourceDetailScreenState extends State { 'ressource sensible sans stockage protégé explicite.', style: Theme.of(context).textTheme.bodySmall ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, ), ), ], diff --git a/mobile/lib/features/resources/resources_screen.dart b/mobile/lib/features/resources/resources_screen.dart index b51da556b..6e4e59aeb 100644 --- a/mobile/lib/features/resources/resources_screen.dart +++ b/mobile/lib/features/resources/resources_screen.dart @@ -257,8 +257,9 @@ class _SectionTitle extends StatelessWidget { const SizedBox(height: MakoloSpacing.xs), Text( subtitle, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ), diff --git a/mobile/lib/navigation/avatar_sheet.dart b/mobile/lib/navigation/avatar_sheet.dart index 4f27485c5..64d887759 100644 --- a/mobile/lib/navigation/avatar_sheet.dart +++ b/mobile/lib/navigation/avatar_sheet.dart @@ -98,12 +98,12 @@ class MakoloAvatarSheet extends StatelessWidget { children: [ CircleAvatar( radius: 24, - backgroundColor: Theme.of(context) - .colorScheme - .primaryContainer, - foregroundColor: Theme.of(context) - .colorScheme - .onPrimaryContainer, + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, child: firstLetter == null ? const Icon(Icons.person_outline) : Text( diff --git a/mobile/lib/navigation/shell_header.dart b/mobile/lib/navigation/shell_header.dart index 2298eefc4..74d21365e 100644 --- a/mobile/lib/navigation/shell_header.dart +++ b/mobile/lib/navigation/shell_header.dart @@ -199,9 +199,9 @@ class MakoloPrimaryHeader extends StatelessWidget child: Text( avatarLetter!, style: TextStyle( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, fontWeight: FontWeight.w700, ), ), diff --git a/mobile/lib/notifications/notification_screens.dart b/mobile/lib/notifications/notification_screens.dart index 5c51defe2..58c042528 100644 --- a/mobile/lib/notifications/notification_screens.dart +++ b/mobile/lib/notifications/notification_screens.dart @@ -181,7 +181,8 @@ class _NotificationInboxScreenState extends State { title: _onlyUnread ? 'Aucune notification non lue.' : 'Aucune notification.', - body: 'Les signaux utiles apparaîtront ici lorsqu’ils auront quelque chose à vous porter.', + body: + 'Les signaux utiles apparaîtront ici lorsqu’ils auront quelque chose à vous porter.', ), onRetry: _refresh, content: Column( @@ -386,7 +387,8 @@ class _NotificationPreferencesScreenState } on Object { if (mounted) { setState( - () => _message = 'Les préférences n’ont pas été confirmées. Réessayez lorsque Makolo est joignable.', + () => _message = + 'Les préférences n’ont pas été confirmées. Réessayez lorsque Makolo est joignable.', ); } } finally { diff --git a/mobile/lib/platform/maps/makolo_map_view.dart b/mobile/lib/platform/maps/makolo_map_view.dart index 2bb0ae42f..7cf344e3d 100644 --- a/mobile/lib/platform/maps/makolo_map_view.dart +++ b/mobile/lib/platform/maps/makolo_map_view.dart @@ -65,11 +65,12 @@ class MakoloMapView extends StatelessWidget { enum MakoloMapRuntimeState { disabled, loading, ready, error } -typedef MakoloMapFallbackBuilder = Widget Function( - BuildContext context, - MakoloMapRuntimeState state, - VoidCallback retry, -); +typedef MakoloMapFallbackBuilder = + Widget Function( + BuildContext context, + MakoloMapRuntimeState state, + VoidCallback retry, + ); class ConfiguredMakoloMapView extends StatefulWidget { const ConfiguredMakoloMapView({ diff --git a/mobile/lib/platform/notifications/push_signal_processor.dart b/mobile/lib/platform/notifications/push_signal_processor.dart index b0cf2a434..6d9acba77 100644 --- a/mobile/lib/platform/notifications/push_signal_processor.dart +++ b/mobile/lib/platform/notifications/push_signal_processor.dart @@ -8,9 +8,8 @@ class NormalizedPushSignal { final StructuredDestination? destination; } -typedef PushSignalNormalizer = Future Function( - PushSignal signal, -); +typedef PushSignalNormalizer = + Future Function(PushSignal signal); typedef PushSourceInvalidator = Future Function(String sourceKey); typedef PushOwnerRefresh = Future Function(Set sourceKeys); diff --git a/mobile/lib/presentation/mps/mps_native_renderer.dart b/mobile/lib/presentation/mps/mps_native_renderer.dart index cab02d9f5..50a6e58fd 100644 --- a/mobile/lib/presentation/mps/mps_native_renderer.dart +++ b/mobile/lib/presentation/mps/mps_native_renderer.dart @@ -87,8 +87,10 @@ class MpsNativeRenderer extends StatelessWidget { return Text( _value(props['value']), style: level <= 1 - ? Theme.of(context).textTheme.headlineMedium - ?.copyWith(color: palette.text, fontWeight: FontWeight.w800) + ? Theme.of(context).textTheme.headlineMedium?.copyWith( + color: palette.text, + fontWeight: FontWeight.w800, + ) : Theme.of(context).textTheme.titleLarge?.copyWith( color: palette.text, fontWeight: FontWeight.w700, @@ -97,8 +99,9 @@ class MpsNativeRenderer extends StatelessWidget { case 'Subheading': return Text( _value(props['value']), - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(color: palette.muted), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: palette.muted), ); case 'Text': case 'Footer': @@ -135,8 +138,9 @@ class MpsNativeRenderer extends StatelessWidget { ? const SizedBox.shrink() : Text( value, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(color: palette.text), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: palette.text), ); case 'AccessSummary': final access = mpsMap(package.artifact.context['access']); @@ -248,8 +252,9 @@ class _InfoBlock extends StatelessWidget { padding: const EdgeInsets.only(bottom: MakoloSpacing.xs), child: Text( line, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: palette.text), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: palette.text), ), ), ], diff --git a/mobile/lib/runtime/firebase_runtime_bootstrap.dart b/mobile/lib/runtime/firebase_runtime_bootstrap.dart index eec1de5a3..25a384d04 100644 --- a/mobile/lib/runtime/firebase_runtime_bootstrap.dart +++ b/mobile/lib/runtime/firebase_runtime_bootstrap.dart @@ -24,9 +24,8 @@ class PluginFirebaseRuntime implements FirebaseRuntime { FirebaseMessaging get messaging => FirebaseMessaging.instance; } -typedef BackgroundMessageRegistrar = void Function( - Future Function(RemoteMessage) handler, -); +typedef BackgroundMessageRegistrar = + void Function(Future Function(RemoteMessage) handler); class FirebaseRuntimeBootstrap { FirebaseRuntimeBootstrap({ diff --git a/mobile/lib/runtime/observability_bootstrap.dart b/mobile/lib/runtime/observability_bootstrap.dart index 1b1e4a4c9..3a54b9331 100644 --- a/mobile/lib/runtime/observability_bootstrap.dart +++ b/mobile/lib/runtime/observability_bootstrap.dart @@ -4,11 +4,8 @@ import '../app/environment.dart'; import '../observability/observability.dart'; import 'runtime_service_status.dart'; -typedef SentryInitializer = Future Function( - String dsn, - String environment, - String? release, -); +typedef SentryInitializer = + Future Function(String dsn, String environment, String? release); Future _initializeSentry( String dsn, diff --git a/mobile/lib/sync/outbox/outbox_processor.dart b/mobile/lib/sync/outbox/outbox_processor.dart index 54bf9af7c..b9b5526d1 100644 --- a/mobile/lib/sync/outbox/outbox_processor.dart +++ b/mobile/lib/sync/outbox/outbox_processor.dart @@ -9,13 +9,11 @@ enum OutboxResolution { failed, } -typedef OutboxHandler = Future Function( - OutboxOperation operation, -); +typedef OutboxHandler = + Future Function(OutboxOperation operation); -typedef OutboxReconciler = Future Function( - OutboxOperation operation, -); +typedef OutboxReconciler = + Future Function(OutboxOperation operation); class OutboxProcessor { OutboxProcessor({ diff --git a/mobile/lib/sync/sync_source.dart b/mobile/lib/sync/sync_source.dart index 79384332c..a33710aaa 100644 --- a/mobile/lib/sync/sync_source.dart +++ b/mobile/lib/sync/sync_source.dart @@ -113,10 +113,11 @@ class SyncApplyContext { } typedef SyncSourceParser = AcquiredProjection Function(ApiResponse response); -typedef SyncSourceApplier = Future Function( - SyncApplyContext context, - AcquiredProjection projection, -); +typedef SyncSourceApplier = + Future Function( + SyncApplyContext context, + AcquiredProjection projection, + ); class SyncSourceDefinition { const SyncSourceDefinition({ diff --git a/mobile/test/actor_context_stabilization_test.dart b/mobile/test/actor_context_stabilization_test.dart index c445bdefc..2d55c5083 100644 --- a/mobile/test/actor_context_stabilization_test.dart +++ b/mobile/test/actor_context_stabilization_test.dart @@ -119,34 +119,37 @@ void main() { }, ); - test('revocation still falls back to Personal when the same Space remains active', () async { - final store = _DelayedActorStore(); - final controller = await ActorContextController.restore( - profileId: 'profile-a', - store: store, - ); - addTearDown(controller.dispose); - - final space = SpaceActorIdentity(id: 'space-a', slug: 'old-slug'); - await controller.selectSpace(space); - - final validationStarted = Completer(); - final authoritativeResult = Completer(); - final validation = controller.revalidateSpaceContext((candidate) { - expect(candidate.id, 'space-a'); - validationStarted.complete(); - return authoritativeResult.future; - }); - - await validationStarted.future; - await controller.selectSpace( - SpaceActorIdentity(id: 'space-a', slug: 'new-slug'), - perspective: ActorPerspective.opaque('finance'), - ); - authoritativeResult.complete(false); - - expect(await validation, isFalse); - expect(controller.value, const PersonalActorContext()); - expect(store.values['profile-a'], const PersonalActorContext()); - }); + test( + 'revocation still falls back to Personal when the same Space remains active', + () async { + final store = _DelayedActorStore(); + final controller = await ActorContextController.restore( + profileId: 'profile-a', + store: store, + ); + addTearDown(controller.dispose); + + final space = SpaceActorIdentity(id: 'space-a', slug: 'old-slug'); + await controller.selectSpace(space); + + final validationStarted = Completer(); + final authoritativeResult = Completer(); + final validation = controller.revalidateSpaceContext((candidate) { + expect(candidate.id, 'space-a'); + validationStarted.complete(); + return authoritativeResult.future; + }); + + await validationStarted.future; + await controller.selectSpace( + SpaceActorIdentity(id: 'space-a', slug: 'new-slug'), + perspective: ActorPerspective.opaque('finance'), + ); + authoritativeResult.complete(false); + + expect(await validation, isFalse); + expect(controller.value, const PersonalActorContext()); + expect(store.values['profile-a'], const PersonalActorContext()); + }, + ); } diff --git a/mobile/test/api_client_test.dart b/mobile/test/api_client_test.dart index d6cbd343e..555b7c251 100644 --- a/mobile/test/api_client_test.dart +++ b/mobile/test/api_client_test.dart @@ -11,10 +11,11 @@ import 'package:makolo_mobile/network/makolo_api_client.dart'; import 'fakes.dart'; -typedef RequestHandler = Future Function( - RequestOptions options, - Stream? requestStream, -); +typedef RequestHandler = + Future Function( + RequestOptions options, + Stream? requestStream, + ); class TestHttpAdapter implements HttpClientAdapter { TestHttpAdapter(this.handler); diff --git a/mobile/test/app_shell_test.dart b/mobile/test/app_shell_test.dart index 1184969cb..b53131040 100644 --- a/mobile/test/app_shell_test.dart +++ b/mobile/test/app_shell_test.dart @@ -109,8 +109,9 @@ Future _pumpRouter( } if (textScale != 1) { result = MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: result, ); } diff --git a/mobile/test/ar_access_real_world_action_test.dart b/mobile/test/ar_access_real_world_action_test.dart index 05fd0c0e3..16acafe4c 100644 --- a/mobile/test/ar_access_real_world_action_test.dart +++ b/mobile/test/ar_access_real_world_action_test.dart @@ -467,24 +467,27 @@ void main() { expect(openedLive, isFalse); }); - test('credential presentation requires the owner capability even when summary says presentable', () { - final projection = StoredProjection( - kind: DayOfRepository.projectionKind, - resourceKey: 'occurrence-1', - schemaVersion: 1, - payload: dayOfPayload(presentCredentialCapability: false), - receivedAt: DateTime.utc(2026, 9, 30, 15), - ); + test( + 'credential presentation requires the owner capability even when summary says presentable', + () { + final projection = StoredProjection( + kind: DayOfRepository.projectionKind, + resourceKey: 'occurrence-1', + schemaVersion: 1, + payload: dayOfPayload(presentCredentialCapability: false), + receivedAt: DateTime.utc(2026, 9, 30, 15), + ); - final presentation = const DayOfSelector().select( - projection: projection, - source: DayOfSourceState.unknown, - now: DateTime.utc(2026, 9, 30, 15, 5), - ); + final presentation = const DayOfSelector().select( + projection: projection, + source: DayOfSourceState.unknown, + now: DateTime.utc(2026, 9, 30, 15, 5), + ); - expect(presentation.accesses.single.canPresentCredential, isFalse); - expect(presentation.canOpenLive, isTrue); - }); + expect(presentation.accesses.single.canPresentCredential, isFalse); + expect(presentation.canOpenLive, isTrue); + }, + ); test('owner 404 invalidates only the keyed Access snapshot', () async { final tokens = MemoryTokenStore( diff --git a/mobile/test/discovery_shell_contract_test.dart b/mobile/test/discovery_shell_contract_test.dart index 76d20897d..556a41971 100644 --- a/mobile/test/discovery_shell_contract_test.dart +++ b/mobile/test/discovery_shell_contract_test.dart @@ -4,8 +4,9 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('Discover root has no nested toolbar or permanent search field', () { - final source = File('lib/features/discovery/discovery_screens.dart') - .readAsStringSync(); + final source = File( + 'lib/features/discovery/discovery_screens.dart', + ).readAsStringSync(); final rootEnd = source.indexOf('class DiscoverySearchScreen'); final root = source.substring(0, rootEnd); @@ -23,13 +24,15 @@ void main() { test( 'Discover map uses the configured MapLibre runtime from the same field', () { - final screens = File('lib/features/discovery/discovery_screens.dart') - .readAsStringSync(); + final screens = File( + 'lib/features/discovery/discovery_screens.dart', + ).readAsStringSync(); final presentation = File( 'lib/features/discovery/discovery_mature_view.dart', ).readAsStringSync(); - final routes = File('lib/features/discovery/discovery_routes.dart') - .readAsStringSync(); + final routes = File( + 'lib/features/discovery/discovery_routes.dart', + ).readAsStringSync(); expect(presentation, contains('ConfiguredMakoloMapView(')); expect(presentation, contains('MakoloSpatialFrame(')); diff --git a/mobile/test/file_media_core_test.dart b/mobile/test/file_media_core_test.dart index 8eeb02153..15937d822 100644 --- a/mobile/test/file_media_core_test.dart +++ b/mobile/test/file_media_core_test.dart @@ -101,8 +101,9 @@ void main() { ); final owner = ConfirmingOwnerTransfer(); - final receipt = await FileTransferCoordinator(store) - .uploadToOwner(fileId: 'capture-a', owner: owner); + final receipt = await FileTransferCoordinator( + store, + ).uploadToOwner(fileId: 'capture-a', owner: owner); expect(receipt.confirmed, isTrue); expect(receipt.remoteVersion, 'server-version-1'); diff --git a/mobile/test/file_transfer_continuity_test.dart b/mobile/test/file_transfer_continuity_test.dart index 888bdc0b2..ee4770563 100644 --- a/mobile/test/file_transfer_continuity_test.dart +++ b/mobile/test/file_transfer_continuity_test.dart @@ -95,8 +95,9 @@ void main() { sensitivity: 'private', ); - final result = await FileTransferCoordinator(store) - .transferToOwner(fileId: 'file-2', owner: RecoverableOwner()); + final result = await FileTransferCoordinator( + store, + ).transferToOwner(fileId: 'file-2', owner: RecoverableOwner()); expect(result.state, FileTransferState.failedRecoverable); expect(result.errorCode, 'network_unreachable'); diff --git a/mobile/test/mps_repository_test.dart b/mobile/test/mps_repository_test.dart index 4ca5c7f62..d6f019e92 100644 --- a/mobile/test/mps_repository_test.dart +++ b/mobile/test/mps_repository_test.dart @@ -69,47 +69,50 @@ void main() { expect(value.themeTokens['accent'], '#5232DB'); }); - test('missing custom definitions degrade locally without losing artifact context', () async { - final database = MakoloDatabase.memory(); - addTearDown(database.close); - final store = ProfileStore(database, 'profile-1'); - final repository = MpsPresentationRepository( - store: store, - profileId: 'profile-1', - ); + test( + 'missing custom definitions degrade locally without losing artifact context', + () async { + final database = MakoloDatabase.memory(); + addTearDown(database.close); + final store = ProfileStore(database, 'profile-1'); + final repository = MpsPresentationRepository( + store: store, + profileId: 'profile-1', + ); - await store.putProjection( - kind: mpsArtifactProjectionKind, - resourceKey: repository.activityArtifactKey('activity-1', 'invitation'), - schemaVersion: 1, - payload: const { - 'identity': { - 'kind': 'mps_artifact', - 'resource_key': 'activity:activity-1:invitation', - }, - 'purpose': 'invitation', - 'renderer': { - 'contract': 'mps.native.v1', - 'schema_version': 1, - 'minimum_renderer_version': 1, - }, - 'template': {'resource_key': '99', 'builtin': false}, - 'theme': {'resource_key': '88', 'builtin': false}, - 'context': { - 'activity': {'display_title': 'Offline Activity'}, + await store.putProjection( + kind: mpsArtifactProjectionKind, + resourceKey: repository.activityArtifactKey('activity-1', 'invitation'), + schemaVersion: 1, + payload: const { + 'identity': { + 'kind': 'mps_artifact', + 'resource_key': 'activity:activity-1:invitation', + }, + 'purpose': 'invitation', + 'renderer': { + 'contract': 'mps.native.v1', + 'schema_version': 1, + 'minimum_renderer_version': 1, + }, + 'template': {'resource_key': '99', 'builtin': false}, + 'theme': {'resource_key': '88', 'builtin': false}, + 'context': { + 'activity': {'display_title': 'Offline Activity'}, + }, + 'capabilities': [], + 'links': {}, }, - 'capabilities': [], - 'links': {}, - }, - ); + ); - final value = await repository.readActivityPackage( - 'activity-1', - 'invitation', - ); - expect(value, isNotNull); - expect(value!.usedFallback, isTrue); - expect(value.artifact.context['activity'], isNotNull); - expect(value.manifest, mpsEssentialManifest); - }); + final value = await repository.readActivityPackage( + 'activity-1', + 'invitation', + ); + expect(value, isNotNull); + expect(value!.usedFallback, isTrue); + expect(value.artifact.context['activity'], isNotNull); + expect(value.manifest, mpsEssentialManifest); + }, + ); } diff --git a/mobile/test/native_launch_contract_test.dart b/mobile/test/native_launch_contract_test.dart index bd3b577f5..101afe238 100644 --- a/mobile/test/native_launch_contract_test.dart +++ b/mobile/test/native_launch_contract_test.dart @@ -4,16 +4,18 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('native launch uses Makolo indigo and the canonical white Mark', () { - final colors = File('android/app/src/main/res/values/colors.xml') - .readAsStringSync(); + final colors = File( + 'android/app/src/main/res/values/colors.xml', + ).readAsStringSync(); final launch = File( 'android/app/src/main/res/drawable/launch_background.xml', ).readAsStringSync(); final launchNight = File( 'android/app/src/main/res/drawable-night/launch_background.xml', ).readAsStringSync(); - final launch31 = File('android/app/src/main/res/values-v31/styles.xml') - .readAsStringSync(); + final launch31 = File( + 'android/app/src/main/res/values-v31/styles.xml', + ).readAsStringSync(); final launch31Night = File( 'android/app/src/main/res/values-night-v31/styles.xml', ).readAsStringSync(); @@ -35,8 +37,9 @@ void main() { test( 'Android host declares PAR-1B capabilities without storage permissions', () { - final manifest = File('android/app/src/main/AndroidManifest.xml') - .readAsStringSync(); + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); expect(manifest, contains('android.permission.INTERNET')); expect(manifest, contains('android.permission.CAMERA')); @@ -62,8 +65,9 @@ void main() { ); test('Android host accepts share ingress without inventing app links', () { - final manifest = File('android/app/src/main/AndroidManifest.xml') - .readAsStringSync(); + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); expect(manifest, contains('android.intent.action.SEND')); expect(manifest, contains('android.intent.action.SEND_MULTIPLE')); diff --git a/mobile/test/now_cross_surface_contract_test.dart b/mobile/test/now_cross_surface_contract_test.dart index 16e97c66a..3efec78a6 100644 --- a/mobile/test/now_cross_surface_contract_test.dart +++ b/mobile/test/now_cross_surface_contract_test.dart @@ -8,9 +8,11 @@ import 'package:makolo_mobile/features/now/now_selector.dart'; void main() { test('shared server semantics select S1-S5 identically on Flutter', () { - final fixture = jsonDecode( - File('test/fixtures/now_s1_s5_contract.json').readAsStringSync(), - ) as Map; + final fixture = + jsonDecode( + File('test/fixtures/now_s1_s5_contract.json').readAsStringSync(), + ) + as Map; final projection = StoredProjection( kind: 'personal.now', schemaVersion: 1, diff --git a/mobile/test/now_gallery_scenarios_test.dart b/mobile/test/now_gallery_scenarios_test.dart index 781e8f22e..68de4b193 100644 --- a/mobile/test/now_gallery_scenarios_test.dart +++ b/mobile/test/now_gallery_scenarios_test.dart @@ -55,16 +55,16 @@ void main() { }); test('S4 Gallery is a real explicit waiting response', () { - final situation = NowGalleryScenarios.select('now-legitimate-waiting') - .situations - .single; + final situation = NowGalleryScenarios.select( + 'now-legitimate-waiting', + ).situations.single; expect(topologyFor(situation), NowTopology.waiting); }); test('S5 Gallery is a justified composition', () { - final situation = NowGalleryScenarios.select('now-multiple') - .situations - .single; + final situation = NowGalleryScenarios.select( + 'now-multiple', + ).situations.single; expect(topologyFor(situation), NowTopology.composition); }); diff --git a/mobile/test/now_media_in_app_test.dart b/mobile/test/now_media_in_app_test.dart index 91eb146de..62d39afff 100644 --- a/mobile/test/now_media_in_app_test.dart +++ b/mobile/test/now_media_in_app_test.dart @@ -83,7 +83,8 @@ void main() { purpose: NowMediaPurpose.understand, kind: NowMediaKind.image, authorized: false, - url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + url: + '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', ), ), ), @@ -123,7 +124,8 @@ void main() { target: NowMediaTarget.situation, purpose: NowMediaPurpose.prepare, kind: NowMediaKind.unknown, - url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + url: + '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', authorized: true, ), ), diff --git a/mobile/test/now_media_viewer_test.dart b/mobile/test/now_media_viewer_test.dart index 3851134ad..eef6e9195 100644 --- a/mobile/test/now_media_viewer_test.dart +++ b/mobile/test/now_media_viewer_test.dart @@ -4,7 +4,8 @@ import 'package:makolo_mobile/presentation/contracts/now_presentation.dart'; NowMediaBindingPresentation binding({ bool authorized = true, - String? url = '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + String? url = + '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', }) => NowMediaBindingPresentation( resourceRef: 'journey_artifact:11111111-2222-4333-8444-555555555555', target: NowMediaTarget.situation, @@ -33,7 +34,8 @@ void main() { expect( nowAuthorizedMediaPath( binding( - url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=admin', + url: + '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=admin', ), ), isNull, @@ -41,7 +43,8 @@ void main() { expect( nowAuthorizedMediaPath( binding( - url: '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', + url: + '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', ), ), isNull, @@ -54,7 +57,8 @@ void main() { purpose: NowMediaPurpose.prepare, kind: NowMediaKind.document, authorized: true, - url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', + url: + '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', ), ), 'api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', @@ -70,7 +74,8 @@ void main() { purpose: NowMediaPurpose.prepare, kind: NowMediaKind.document, authorized: true, - mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + mimeType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', url: '$original?view=text', downloadUrl: original, ); @@ -90,7 +95,8 @@ void main() { kind: NowMediaKind.document, authorized: true, url: '$original?view=text', - downloadUrl: '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', + downloadUrl: + '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', ); expect(nowAuthorizedOriginalDownloadPath(hostile), isNull); }); diff --git a/mobile/test/share_ingress_test.dart b/mobile/test/share_ingress_test.dart index 822643c63..f48398dc5 100644 --- a/mobile/test/share_ingress_test.dart +++ b/mobile/test/share_ingress_test.dart @@ -34,8 +34,9 @@ void main() { ], ); - final capture = await SharedPayloadCoordinator(store) - .capture(payload, fileIdFor: (_, _) => 'opaque-capture-1'); + final capture = await SharedPayloadCoordinator( + store, + ).capture(payload, fileIdFor: (_, _) => 'opaque-capture-1'); expect(capture.files.single.owner, 'InboundCapture'); expect(capture.files.single.path, startsWith(staging.path)); diff --git a/mobile/test/shell_recovery_persistence_test.dart b/mobile/test/shell_recovery_persistence_test.dart index 3ec8c9256..bc3f8bdae 100644 --- a/mobile/test/shell_recovery_persistence_test.dart +++ b/mobile/test/shell_recovery_persistence_test.dart @@ -21,13 +21,16 @@ void main() { expect(await reopened.readShellLocation('profile-b'), '/me'); }); - test('launch recovery uses persisted shell location without creating actor state', () { - final recovery = SessionRecoveryController(); - recovery.restoreLaunchLocation('/space/us'); - - expect(recovery.initialLocation(), '/space/us'); - expect(recovery.awaitingAuthentication, isFalse); - }); + test( + 'launch recovery uses persisted shell location without creating actor state', + () { + final recovery = SessionRecoveryController(); + recovery.restoreLaunchLocation('/space/us'); + + expect(recovery.initialLocation(), '/space/us'); + expect(recovery.awaitingAuthentication, isFalse); + }, + ); test('account switch clears restored navigation context', () { final recovery = SessionRecoveryController(); diff --git a/mobile/test/space_actor_shell_test.dart b/mobile/test/space_actor_shell_test.dart index a30624a1e..7f927df67 100644 --- a/mobile/test/space_actor_shell_test.dart +++ b/mobile/test/space_actor_shell_test.dart @@ -242,8 +242,9 @@ Future _pump( builder: (context, child) => textScale == 1 ? child! : MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), ), diff --git a/mobile/test/sync_engine_test.dart b/mobile/test/sync_engine_test.dart index 43fa6b55d..27c55fcd5 100644 --- a/mobile/test/sync_engine_test.dart +++ b/mobile/test/sync_engine_test.dart @@ -122,8 +122,9 @@ void main() { 1, ); expect( - (await store.readProjection('personal.now')) - ?.payload['actor_attention_state'], + (await store.readProjection( + 'personal.now', + ))?.payload['actor_attention_state'], 'active', ); final interoperability = await store.readProjection( diff --git a/mobile/test/workmanager_scheduler_test.dart b/mobile/test/workmanager_scheduler_test.dart index 135b2e177..841a9ed74 100644 --- a/mobile/test/workmanager_scheduler_test.dart +++ b/mobile/test/workmanager_scheduler_test.dart @@ -4,16 +4,14 @@ import 'package:makolo_mobile/background/workmanager_scheduler.dart'; void main() { test('workmanager payload decodes to the Makolo background contract', () { - final task = decodeWorkmanagerTask( - WorkmanagerBackgroundScheduler.taskName, - { - 'task_id': 'upload-1', - 'kind': 'upload', - 'profile_id': 'profile-a', - 'operation_id': 'operation-1', - 'requires_network': true, - }, - ); + final task = + decodeWorkmanagerTask(WorkmanagerBackgroundScheduler.taskName, { + 'task_id': 'upload-1', + 'kind': 'upload', + 'profile_id': 'profile-a', + 'operation_id': 'operation-1', + 'requires_network': true, + }); expect(task?.id, 'upload-1'); expect(task?.kind, BackgroundTaskKind.upload); @@ -23,15 +21,13 @@ void main() { }); test('workmanager round-trip preserves network-independent maintenance', () { - final task = decodeWorkmanagerTask( - WorkmanagerBackgroundScheduler.taskName, - { - 'task_id': 'cache-1', - 'kind': 'cacheMaintenance', - 'profile_id': 'profile-a', - 'requires_network': false, - }, - ); + final task = + decodeWorkmanagerTask(WorkmanagerBackgroundScheduler.taskName, { + 'task_id': 'cache-1', + 'kind': 'cacheMaintenance', + 'profile_id': 'profile-a', + 'requires_network': false, + }); expect(task?.kind, BackgroundTaskKind.cacheMaintenance); expect(task?.requiresNetwork, isFalse); From e39fb69639e3a189f28730e447c0759c63751b3b Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 11:57:43 +0200 Subject: [PATCH 11/16] style(mobile): use active canonical Dart 3.13.3 formatter --- mobile/lib/app/makolo_app.dart | 11 +-- mobile/lib/app/runtime/runtime_builder.dart | 5 +- mobile/lib/data/files/media_cache.dart | 11 ++- mobile/lib/design/behavior_primitives.dart | 18 ++-- mobile/lib/design/makolo_components.dart | 6 +- mobile/lib/design/makolo_patterns.dart | 5 +- mobile/lib/design/presentation_media.dart | 5 +- mobile/lib/design/surface_states.dart | 3 +- .../access/access_credential_screen.dart | 3 +- .../features/access/access_detail_screen.dart | 6 +- mobile/lib/features/access/access_routes.dart | 3 +- .../features/auth/auth_error_messages.dart | 6 +- .../features/auth/forgot_password_screen.dart | 3 +- mobile/lib/features/auth/login_screen.dart | 3 +- mobile/lib/features/auth/signup_screen.dart | 3 +- .../continuity/conversation_screens.dart | 21 ++--- .../features/continuity/objective_screen.dart | 3 +- .../interoperability/connections_screen.dart | 3 +- .../journey/journey_detail_screen.dart | 6 +- mobile/lib/features/live/live_screen.dart | 3 +- mobile/lib/features/mark/mark_repository.dart | 6 +- mobile/lib/features/mark/mark_screen.dart | 5 +- mobile/lib/features/me/me_routes.dart | 3 +- mobile/lib/features/now/now_media_viewer.dart | 6 +- .../features/onboarding/onboarding_flow.dart | 9 +- .../features/personal/projection_screen.dart | 3 +- .../preparation_resources_screen.dart | 6 +- .../requirement_detail_screen.dart | 3 +- .../questionnaire_form_screen.dart | 6 +- .../questionnaires/questionnaire_routes.dart | 3 +- .../resources/resource_detail_screen.dart | 6 +- .../features/resources/resources_screen.dart | 5 +- mobile/lib/navigation/avatar_sheet.dart | 12 +-- mobile/lib/navigation/shell_header.dart | 6 +- .../notifications/notification_screens.dart | 6 +- mobile/lib/platform/maps/makolo_map_view.dart | 11 ++- .../notifications/push_signal_processor.dart | 5 +- .../presentation/mps/mps_native_renderer.dart | 21 ++--- .../runtime/firebase_runtime_bootstrap.dart | 5 +- .../lib/runtime/observability_bootstrap.dart | 7 +- mobile/lib/sync/outbox/outbox_processor.dart | 10 ++- mobile/lib/sync/sync_source.dart | 9 +- .../actor_context_stabilization_test.dart | 63 +++++++------- mobile/test/api_client_test.dart | 9 +- mobile/test/app_shell_test.dart | 5 +- .../ar_access_real_world_action_test.dart | 35 ++++---- .../test/discovery_shell_contract_test.dart | 15 ++-- mobile/test/file_media_core_test.dart | 5 +- .../test/file_transfer_continuity_test.dart | 5 +- mobile/test/mps_repository_test.dart | 83 +++++++++---------- mobile/test/native_launch_contract_test.dart | 20 ++--- .../test/now_cross_surface_contract_test.dart | 8 +- mobile/test/now_gallery_scenarios_test.dart | 12 +-- mobile/test/now_media_in_app_test.dart | 6 +- mobile/test/now_media_viewer_test.dart | 18 ++-- mobile/test/share_ingress_test.dart | 5 +- .../test/shell_recovery_persistence_test.dart | 17 ++-- mobile/test/space_actor_shell_test.dart | 5 +- mobile/test/sync_engine_test.dart | 5 +- mobile/test/workmanager_scheduler_test.dart | 34 ++++---- 60 files changed, 275 insertions(+), 355 deletions(-) diff --git a/mobile/lib/app/makolo_app.dart b/mobile/lib/app/makolo_app.dart index f414bdab4..e2d7f8959 100644 --- a/mobile/lib/app/makolo_app.dart +++ b/mobile/lib/app/makolo_app.dart @@ -53,10 +53,8 @@ class MakoloApp extends ConsumerWidget { home: MakoloSystemUi( child: Scaffold( body: MakoloErrorState( - message: - 'Makolo n’a pas pu ouvrir les données locales de cet appareil.', - preservedMessage: - 'Aucune donnée locale n’a été supprimée. Vous pouvez réessayer.', + message: 'Makolo n’a pas pu ouvrir les données locales de cet appareil.', + preservedMessage: 'Aucune donnée locale n’a été supprimée. Vous pouvez réessayer.', onRetry: () => ref.invalidate(appRuntimeProvider), ), ), @@ -107,9 +105,8 @@ class MakoloApp extends ConsumerWidget { if (preferences.reduceMotion) { launched = MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: true), + data: MediaQuery.of(context) + .copyWith(disableAnimations: true), child: launched, ); } diff --git a/mobile/lib/app/runtime/runtime_builder.dart b/mobile/lib/app/runtime/runtime_builder.dart index da85b2533..221e4cef2 100644 --- a/mobile/lib/app/runtime/runtime_builder.dart +++ b/mobile/lib/app/runtime/runtime_builder.dart @@ -27,8 +27,9 @@ import '../session_recovery.dart'; import 'actor_context_controller.dart'; import 'app_runtime.dart'; -typedef LocationCapabilityFactory = - LocationCapability Function(MakoloRuntimeConfig config); +typedef LocationCapabilityFactory = LocationCapability Function( + MakoloRuntimeConfig config, +); Future buildAppRuntime({ required TokenStore tokens, diff --git a/mobile/lib/data/files/media_cache.dart b/mobile/lib/data/files/media_cache.dart index e7ea6edbf..868ede553 100644 --- a/mobile/lib/data/files/media_cache.dart +++ b/mobile/lib/data/files/media_cache.dart @@ -3,12 +3,11 @@ import 'dart:io'; import '../../network/makolo_api_client.dart'; import 'profile_paths.dart'; -typedef MediaDownloader = - Future Function( - String destinationPath, - MakoloCancelHandle cancel, - TransferProgress? onProgress, - ); +typedef MediaDownloader = Future Function( + String destinationPath, + MakoloCancelHandle cancel, + TransferProgress? onProgress, +); class ProfileMediaCache { ProfileMediaCache({required this.profileId, required this.directory}); diff --git a/mobile/lib/design/behavior_primitives.dart b/mobile/lib/design/behavior_primitives.dart index 56f3b7f13..6089fec9b 100644 --- a/mobile/lib/design/behavior_primitives.dart +++ b/mobile/lib/design/behavior_primitives.dart @@ -14,9 +14,8 @@ class MakoloSkeleton extends StatelessWidget { Widget build(BuildContext context) { final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; - final color = Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.7); + final color = Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.7); return Semantics( label: 'Chargement du contenu', @@ -162,9 +161,8 @@ class MakoloNotice extends StatelessWidget { borderRadius: BorderRadius.circular(MakoloRadii.medium), boxShadow: [ BoxShadow( - color: Theme.of( - context, - ).colorScheme.shadow.withValues(alpha: 0.06), + color: Theme.of(context).colorScheme.shadow + .withValues(alpha: 0.06), blurRadius: 16, offset: const Offset(0, 6), ), @@ -178,9 +176,8 @@ class MakoloNotice extends StatelessWidget { Expanded( child: Text( message, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), ), ), if (actionLabel != null && onAction != null) ...[ @@ -470,8 +467,7 @@ class PermissionExplainer extends StatelessWidget { if (denied) ...[ const SizedBox(height: MakoloSpacing.md), Text( - deniedMessage ?? - 'Cette autorisation est refusée. Vous pouvez continuer sans cette fonction et la réactiver plus tard si nécessaire.', + deniedMessage ?? 'Cette autorisation est refusée. Vous pouvez continuer sans cette fonction et la réactiver plus tard si nécessaire.', ), ], const SizedBox(height: MakoloSpacing.lg), diff --git a/mobile/lib/design/makolo_components.dart b/mobile/lib/design/makolo_components.dart index 62d56238c..de5a4f55c 100644 --- a/mobile/lib/design/makolo_components.dart +++ b/mobile/lib/design/makolo_components.dart @@ -56,10 +56,8 @@ class MakoloStatus extends StatelessWidget { Flexible( child: Text( label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: accent, - fontWeight: FontWeight.w700, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: accent, fontWeight: FontWeight.w700), ), ), ], diff --git a/mobile/lib/design/makolo_patterns.dart b/mobile/lib/design/makolo_patterns.dart index 5eedbf598..55d7f924c 100644 --- a/mobile/lib/design/makolo_patterns.dart +++ b/mobile/lib/design/makolo_patterns.dart @@ -317,9 +317,8 @@ class MakoloDetailHeader extends StatelessWidget { if (eyebrow != null) ...[ Text( eyebrow!, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: Theme.of(context).colorScheme.primary), ), const SizedBox(height: MakoloSpacing.xs), ], diff --git a/mobile/lib/design/presentation_media.dart b/mobile/lib/design/presentation_media.dart index f0641457f..523dacb39 100644 --- a/mobile/lib/design/presentation_media.dart +++ b/mobile/lib/design/presentation_media.dart @@ -73,9 +73,8 @@ class MakoloMediaPlaceholder extends StatelessWidget { Text( label!, textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: foreground), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: foreground), ), ], ], diff --git a/mobile/lib/design/surface_states.dart b/mobile/lib/design/surface_states.dart index 59a642556..41e7170df 100644 --- a/mobile/lib/design/surface_states.dart +++ b/mobile/lib/design/surface_states.dart @@ -202,8 +202,7 @@ class MakoloFreshnessNotice extends StatelessWidget { 'Une mise à jour est recommandée lorsque la source est joignable.', MakoloFreshnessCue.revalidationRequired => 'Une vérification distante est nécessaire avant l’action.', - MakoloFreshnessCue.expired => - 'Cette observation a expiré. Une revalidation distante est nécessaire avant de continuer.', + MakoloFreshnessCue.expired => 'Cette observation a expiré. Une revalidation distante est nécessaire avant de continuer.', }; } diff --git a/mobile/lib/features/access/access_credential_screen.dart b/mobile/lib/features/access/access_credential_screen.dart index 7863e9558..440872ffe 100644 --- a/mobile/lib/features/access/access_credential_screen.dart +++ b/mobile/lib/features/access/access_credential_screen.dart @@ -122,8 +122,7 @@ class _AccessCredentialScreenState extends State (final value?, _, _) when value.credentialType != 'qr' => const MakoloEmptyState( title: 'Représentation non prise en charge', - body: - 'Cette représentation d’accès ne peut pas encore être affichée ici.', + body: 'Cette représentation d’accès ne peut pas encore être affichée ici.', icon: Icons.lock_outline_rounded, ), (final value?, _, _) => _CredentialQr(credential: value), diff --git a/mobile/lib/features/access/access_detail_screen.dart b/mobile/lib/features/access/access_detail_screen.dart index abf8ddca9..9bed0f206 100644 --- a/mobile/lib/features/access/access_detail_screen.dart +++ b/mobile/lib/features/access/access_detail_screen.dart @@ -125,8 +125,7 @@ class _AccessDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de l’accès…', ), - blockingErrorMessage: - 'Cet accès n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: 'Cet accès n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _AccessContent( presentation: presentation, @@ -211,8 +210,7 @@ class _AccessContent extends StatelessWidget { ), child: MakoloAttentionBlock( title: 'Action réelle', - body: - 'Les informations utiles pour le moment venu sont disponibles.', + body: 'Les informations utiles pour le moment venu sont disponibles.', action: FilledButton.icon( onPressed: () => onOpenDayOf(presentation.dayOf!), icon: const Icon(Icons.directions_walk_rounded), diff --git a/mobile/lib/features/access/access_routes.dart b/mobile/lib/features/access/access_routes.dart index 57b6b8cc3..f2018df22 100644 --- a/mobile/lib/features/access/access_routes.dart +++ b/mobile/lib/features/access/access_routes.dart @@ -83,8 +83,7 @@ List accessRoutes(AppRuntime runtime) => [ if (repository == null || credentialPath == null) { return const MakoloSecondaryScreen( title: 'QR d’accès', - message: - 'Rouvrez ce QR depuis le Jour J afin de revalider son lien propriétaire.', + message: 'Rouvrez ce QR depuis le Jour J afin de revalider son lien propriétaire.', ); } return AccessCredentialScreen( diff --git a/mobile/lib/features/auth/auth_error_messages.dart b/mobile/lib/features/auth/auth_error_messages.dart index 0a9ff382e..bdd5cae0d 100644 --- a/mobile/lib/features/auth/auth_error_messages.dart +++ b/mobile/lib/features/auth/auth_error_messages.dart @@ -42,8 +42,7 @@ String signupErrorMessage(Object error) { if (error is! MakoloApiError) { return authErrorMessage( error, - fallback: - 'Création du compte impossible pour le moment. Réessayez dans un instant.', + fallback: 'Création du compte impossible pour le moment. Réessayez dans un instant.', ); } @@ -103,8 +102,7 @@ Future resolvedSignupErrorMessage( if (error is MakoloApiError) return signupErrorMessage(error); return resolvedAuthErrorMessage( error, - fallback: - 'Création du compte impossible pour le moment. Réessayez dans un instant.', + fallback: 'Création du compte impossible pour le moment. Réessayez dans un instant.', offlineProbe: offlineProbe, ); } diff --git a/mobile/lib/features/auth/forgot_password_screen.dart b/mobile/lib/features/auth/forgot_password_screen.dart index 94ec411a4..86ef9565b 100644 --- a/mobile/lib/features/auth/forgot_password_screen.dart +++ b/mobile/lib/features/auth/forgot_password_screen.dart @@ -63,8 +63,7 @@ class _ForgotPasswordDialogState extends State<_ForgotPasswordDialog> { final api = widget.runtime.api; if (api == null) { setState(() { - _error = - 'La récupération est indisponible sur cette installation pour le moment.'; + _error = 'La récupération est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart index a713b37b5..01f3f8ba4 100644 --- a/mobile/lib/features/auth/login_screen.dart +++ b/mobile/lib/features/auth/login_screen.dart @@ -131,8 +131,7 @@ class _LoginScreenState extends ConsumerState { final api = widget.runtime.api; if (api == null) { setState(() { - _error = - 'La connexion est indisponible sur cette installation pour le moment.'; + _error = 'La connexion est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/auth/signup_screen.dart b/mobile/lib/features/auth/signup_screen.dart index 0751f5d1b..86cb11633 100644 --- a/mobile/lib/features/auth/signup_screen.dart +++ b/mobile/lib/features/auth/signup_screen.dart @@ -303,8 +303,7 @@ class _SignupScreenState extends State { final api = widget.runtime.api; if (api == null) { setState(() { - _error = - 'La création de compte est indisponible sur cette installation pour le moment.'; + _error = 'La création de compte est indisponible sur cette installation pour le moment.'; }); return; } diff --git a/mobile/lib/features/continuity/conversation_screens.dart b/mobile/lib/features/continuity/conversation_screens.dart index e73f8ba9b..23825ca95 100644 --- a/mobile/lib/features/continuity/conversation_screens.dart +++ b/mobile/lib/features/continuity/conversation_screens.dart @@ -571,8 +571,7 @@ class _ConversationDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de la conversation…', ), - blockingErrorMessage: - 'Cette conversation n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: 'Cette conversation n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: ListView( key: const Key('conversation-detail-content'), @@ -617,8 +616,7 @@ class _ConversationDetailScreenState extends State { if (detail.essential.isNotEmpty) MakoloSection( title: 'Essentiel', - description: - 'Les décisions et résultats actuels restent lisibles sans relire tout le passé.', + description: 'Les décisions et résultats actuels restent lisibles sans relire tout le passé.', child: Column( children: [ for ( @@ -726,8 +724,7 @@ class _PointCardState extends State<_PointCard> { await widget.repository.refreshDetail(widget.conversationId); if (!mounted) return; setState( - () => _feedback = - 'Ce point a changé ou la réponse n’a pas été confirmée. Votre ancienne saisie n’a pas été déclarée envoyée.', + () => _feedback = 'Ce point a changé ou la réponse n’a pas été confirmée. Votre ancienne saisie n’a pas été déclarée envoyée.', ); } finally { if (mounted) setState(() => _busy = false); @@ -1047,12 +1044,12 @@ class _ConversationDetail { _string(payload['lifecycle']), ), contextLabel: _string(context['label']), - points: _maps( - payload['points'], - ).map(_ConversationPoint.fromMap).toList(growable: false), - essential: _maps( - payload['essential'], - ).map(_ConversationPoint.fromMap).toList(growable: false), + points: _maps(payload['points']) + .map(_ConversationPoint.fromMap) + .toList(growable: false), + essential: _maps(payload['essential']) + .map(_ConversationPoint.fromMap) + .toList(growable: false), personalState: _ConversationPersonalState.fromMap( _map(payload['personal_state']), ), diff --git a/mobile/lib/features/continuity/objective_screen.dart b/mobile/lib/features/continuity/objective_screen.dart index 9b76d9f8b..3d71519e3 100644 --- a/mobile/lib/features/continuity/objective_screen.dart +++ b/mobile/lib/features/continuity/objective_screen.dart @@ -142,8 +142,7 @@ class _ObjectiveDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement…', ), - blockingErrorMessage: - 'Ce contenu n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: 'Ce contenu n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: widget.kind == ObjectiveDepth.dossier ? _DossierContent( diff --git a/mobile/lib/features/interoperability/connections_screen.dart b/mobile/lib/features/interoperability/connections_screen.dart index 42bcb704e..176c92f83 100644 --- a/mobile/lib/features/interoperability/connections_screen.dart +++ b/mobile/lib/features/interoperability/connections_screen.dart @@ -289,8 +289,7 @@ class _ProfileConnectionsScreenState extends State { height: 520, child: MakoloEmptyState( title: 'Connexions indisponibles hors ligne', - body: - 'Aucune copie locale n’est encore disponible. Le reste de Makolo continue de fonctionner.', + body: 'Aucune copie locale n’est encore disponible. Le reste de Makolo continue de fonctionner.', ), ), ], diff --git a/mobile/lib/features/journey/journey_detail_screen.dart b/mobile/lib/features/journey/journey_detail_screen.dart index 4a88c17e2..2a4030652 100644 --- a/mobile/lib/features/journey/journey_detail_screen.dart +++ b/mobile/lib/features/journey/journey_detail_screen.dart @@ -166,8 +166,7 @@ class _JourneyDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de la démarche…', ), - blockingErrorMessage: - 'Cette démarche n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: 'Cette démarche n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _JourneyContent( presentation: presentation, @@ -294,8 +293,7 @@ class _JourneyContent extends StatelessWidget { onTap: () => onOpenResources(presentation.resourcesLink!), child: const MakoloStatusMetadataAction( title: 'Ressources de préparation', - subtitle: - 'Consultez ce que le propriétaire de la démarche a rendu disponible.', + subtitle: 'Consultez ce que le propriétaire de la démarche a rendu disponible.', action: Icon(Icons.chevron_right_rounded), ), ), diff --git a/mobile/lib/features/live/live_screen.dart b/mobile/lib/features/live/live_screen.dart index 2d405531f..c51a1c529 100644 --- a/mobile/lib/features/live/live_screen.dart +++ b/mobile/lib/features/live/live_screen.dart @@ -186,8 +186,7 @@ class _LiveContent extends StatelessWidget { const SizedBox(height: MakoloSpacing.sm), const MakoloAttentionBlock( title: 'Dernière information connue', - body: - 'La connexion doit être vérifiée pour confirmer la situation actuelle.', + body: 'La connexion doit être vérifiée pour confirmer la situation actuelle.', icon: Icons.schedule_rounded, ), ], diff --git a/mobile/lib/features/mark/mark_repository.dart b/mobile/lib/features/mark/mark_repository.dart index 7d6e06468..b212ad47c 100644 --- a/mobile/lib/features/mark/mark_repository.dart +++ b/mobile/lib/features/mark/mark_repository.dart @@ -106,8 +106,7 @@ class MarkRepository { if (api == null) { return const MarkResult( state: 'offline_draft', - message: - 'Enregistré sur cet appareil. L’action sera possible lorsque Makolo pourra revalider le contexte.', + message: 'Enregistré sur cet appareil. L’action sera possible lorsque Makolo pourra revalider le contexte.', offline: true, ); } @@ -155,8 +154,7 @@ class MarkRepository { } on MakoloTransportError { return const MarkResult( state: 'offline_draft', - message: - 'Enregistré sur cet appareil. Makolo n’a pas pu revalider le contexte à distance.', + message: 'Enregistré sur cet appareil. Makolo n’a pas pu revalider le contexte à distance.', offline: true, ); } on MakoloApiError catch (error) { diff --git a/mobile/lib/features/mark/mark_screen.dart b/mobile/lib/features/mark/mark_screen.dart index fe95278f7..31d67f3a7 100644 --- a/mobile/lib/features/mark/mark_screen.dart +++ b/mobile/lib/features/mark/mark_screen.dart @@ -212,9 +212,8 @@ class _MarkScreenState extends State { void _show(String message) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); } @override diff --git a/mobile/lib/features/me/me_routes.dart b/mobile/lib/features/me/me_routes.dart index ed0cf8f33..75844ef16 100644 --- a/mobile/lib/features/me/me_routes.dart +++ b/mobile/lib/features/me/me_routes.dart @@ -54,8 +54,7 @@ StatefulShellBranch meBranch(AppRuntime runtime) => StatefulShellBranch( if (repository == null || personal == null) { return const MakoloSecondaryScreen( title: 'Ressource', - message: - 'Cette ressource n’est pas disponible sur cet appareil.', + message: 'Cette ressource n’est pas disponible sur cet appareil.', ); } return ResourceDetailScreen( diff --git a/mobile/lib/features/now/now_media_viewer.dart b/mobile/lib/features/now/now_media_viewer.dart index 120aa1382..f6e2b64e0 100644 --- a/mobile/lib/features/now/now_media_viewer.dart +++ b/mobile/lib/features/now/now_media_viewer.dart @@ -55,8 +55,7 @@ String? nowAuthorizedOriginalDownloadPath(NowMediaBindingPresentation binding) { } String nowOriginalDocumentExtension(NowMediaBindingPresentation media) { - return media.mimeType == - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + return media.mimeType == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ? 'docx' : 'txt'; } @@ -171,8 +170,7 @@ class _NowMediaViewerState extends State { if (file != null && await file.exists()) await file.delete(); if (mounted) { setState(() { - _error = - 'Impossible de charger ce média. Vérifiez votre connexion et vos droits.'; + _error = 'Impossible de charger ce média. Vérifiez votre connexion et vos droits.'; _loading = false; }); } diff --git a/mobile/lib/features/onboarding/onboarding_flow.dart b/mobile/lib/features/onboarding/onboarding_flow.dart index 811214eed..7a1fcf1d8 100644 --- a/mobile/lib/features/onboarding/onboarding_flow.dart +++ b/mobile/lib/features/onboarding/onboarding_flow.dart @@ -29,20 +29,17 @@ class _OnboardingFlowState extends State { static const _pages = <({String title, String body, IconData icon})>[ ( title: 'Découvrez ce qui compte vraiment.', - body: - 'Explorez des possibilités réelles sans transformer Makolo en simple fil à parcourir.', + body: 'Explorez des possibilités réelles sans transformer Makolo en simple fil à parcourir.', icon: Icons.explore_outlined, ), ( title: 'Préparez ce qui peut l’être.', - body: - 'Rassemblez le contexte utile avant le moment d’agir et voyez clairement ce qui reste à faire.', + body: 'Rassemblez le contexte utile avant le moment d’agir et voyez clairement ce qui reste à faire.', icon: Icons.inventory_2_outlined, ), ( title: 'Avancez dans l’action réelle.', - body: - 'Retrouvez vos démarches, vos prochaines actions et la continuité nécessaire pour aller jusqu’au bout.', + body: 'Retrouvez vos démarches, vos prochaines actions et la continuité nécessaire pour aller jusqu’au bout.', icon: Icons.arrow_forward_rounded, ), ]; diff --git a/mobile/lib/features/personal/projection_screen.dart b/mobile/lib/features/personal/projection_screen.dart index 9a3beca26..ce5f75d05 100644 --- a/mobile/lib/features/personal/projection_screen.dart +++ b/mobile/lib/features/personal/projection_screen.dart @@ -74,8 +74,7 @@ class ProjectionScreen extends StatelessWidget { if (projection == null) { return const MakoloEmptyState( title: 'Pas encore disponible sur cet appareil', - body: - 'Une première connexion est nécessaire pour rendre ce contenu disponible ici.', + body: 'Une première connexion est nécessaire pour rendre ce contenu disponible ici.', icon: Icons.cloud_off_outlined, ); } diff --git a/mobile/lib/features/preparation/preparation_resources_screen.dart b/mobile/lib/features/preparation/preparation_resources_screen.dart index 7543c4639..d3876cd06 100644 --- a/mobile/lib/features/preparation/preparation_resources_screen.dart +++ b/mobile/lib/features/preparation/preparation_resources_screen.dart @@ -195,14 +195,12 @@ class _PreparationResourcesScreenState state: surface, empty: const MakoloEmptyState( title: 'Rien à préparer ici', - body: - 'Aucun document ou instruction partagé n’est disponible pour cette démarche.', + body: 'Aucun document ou instruction partagé n’est disponible pour cette démarche.', ), initialLoading: const MakoloLoadingState( label: 'Chargement des ressources de préparation…', ), - blockingErrorMessage: - 'Ces ressources ne sont pas disponibles dans votre contexte actuel.', + blockingErrorMessage: 'Ces ressources ne sont pas disponibles dans votre contexte actuel.', onRetry: _refresh, content: ListView.separated( key: const Key('preparation-resources-content'), diff --git a/mobile/lib/features/preparation/requirement_detail_screen.dart b/mobile/lib/features/preparation/requirement_detail_screen.dart index 2339f8614..6a123e51c 100644 --- a/mobile/lib/features/preparation/requirement_detail_screen.dart +++ b/mobile/lib/features/preparation/requirement_detail_screen.dart @@ -147,8 +147,7 @@ class _RequirementDetailScreenState extends State { initialLoading: const MakoloLoadingState( label: 'Chargement de l’élément nécessaire…', ), - blockingErrorMessage: - 'Cet élément n’est pas disponible dans votre contexte actuel.', + blockingErrorMessage: 'Cet élément n’est pas disponible dans votre contexte actuel.', onRetry: _refresh, content: _RequirementContent( presentation: presentation, diff --git a/mobile/lib/features/questionnaires/questionnaire_form_screen.dart b/mobile/lib/features/questionnaires/questionnaire_form_screen.dart index 82496589c..2952a380e 100644 --- a/mobile/lib/features/questionnaires/questionnaire_form_screen.dart +++ b/mobile/lib/features/questionnaires/questionnaire_form_screen.dart @@ -242,10 +242,8 @@ class _QuestionnaireFormScreenState extends State { label: 'Chargement du formulaire…', ) : MakoloErrorState( - message: - 'Ce formulaire n’est pas disponible sur cet appareil.', - preservedMessage: - 'Votre brouillon local, s’il existe, est conservé.', + message: 'Ce formulaire n’est pas disponible sur cet appareil.', + preservedMessage: 'Votre brouillon local, s’il existe, est conservé.', onRetry: _refresh, )) : _FormContent( diff --git a/mobile/lib/features/questionnaires/questionnaire_routes.dart b/mobile/lib/features/questionnaires/questionnaire_routes.dart index 0d625ab84..0d17e0a7e 100644 --- a/mobile/lib/features/questionnaires/questionnaire_routes.dart +++ b/mobile/lib/features/questionnaires/questionnaire_routes.dart @@ -26,8 +26,7 @@ List questionnaireRoutes(AppRuntime runtime) => [ form.detailLink.isEmpty) { return const MakoloSecondaryScreen( title: 'Formulaire', - message: - 'Rouvrez ce formulaire depuis la démarche pour reprendre avec les liens du propriétaire.', + message: 'Rouvrez ce formulaire depuis la démarche pour reprendre avec les liens du propriétaire.', ); } return QuestionnaireFormScreen( diff --git a/mobile/lib/features/resources/resource_detail_screen.dart b/mobile/lib/features/resources/resource_detail_screen.dart index 7a00e49c8..ff7ee5338 100644 --- a/mobile/lib/features/resources/resource_detail_screen.dart +++ b/mobile/lib/features/resources/resource_detail_screen.dart @@ -252,9 +252,9 @@ class _ResourceDetailScreenState extends State { 'ressource sensible sans stockage protégé explicite.', style: Theme.of(context).textTheme.bodySmall ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], diff --git a/mobile/lib/features/resources/resources_screen.dart b/mobile/lib/features/resources/resources_screen.dart index 6e4e59aeb..b51da556b 100644 --- a/mobile/lib/features/resources/resources_screen.dart +++ b/mobile/lib/features/resources/resources_screen.dart @@ -257,9 +257,8 @@ class _SectionTitle extends StatelessWidget { const SizedBox(height: MakoloSpacing.xs), Text( subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), diff --git a/mobile/lib/navigation/avatar_sheet.dart b/mobile/lib/navigation/avatar_sheet.dart index 64d887759..4f27485c5 100644 --- a/mobile/lib/navigation/avatar_sheet.dart +++ b/mobile/lib/navigation/avatar_sheet.dart @@ -98,12 +98,12 @@ class MakoloAvatarSheet extends StatelessWidget { children: [ CircleAvatar( radius: 24, - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of(context) + .colorScheme + .primaryContainer, + foregroundColor: Theme.of(context) + .colorScheme + .onPrimaryContainer, child: firstLetter == null ? const Icon(Icons.person_outline) : Text( diff --git a/mobile/lib/navigation/shell_header.dart b/mobile/lib/navigation/shell_header.dart index 74d21365e..2298eefc4 100644 --- a/mobile/lib/navigation/shell_header.dart +++ b/mobile/lib/navigation/shell_header.dart @@ -199,9 +199,9 @@ class MakoloPrimaryHeader extends StatelessWidget child: Text( avatarLetter!, style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, fontWeight: FontWeight.w700, ), ), diff --git a/mobile/lib/notifications/notification_screens.dart b/mobile/lib/notifications/notification_screens.dart index 58c042528..5c51defe2 100644 --- a/mobile/lib/notifications/notification_screens.dart +++ b/mobile/lib/notifications/notification_screens.dart @@ -181,8 +181,7 @@ class _NotificationInboxScreenState extends State { title: _onlyUnread ? 'Aucune notification non lue.' : 'Aucune notification.', - body: - 'Les signaux utiles apparaîtront ici lorsqu’ils auront quelque chose à vous porter.', + body: 'Les signaux utiles apparaîtront ici lorsqu’ils auront quelque chose à vous porter.', ), onRetry: _refresh, content: Column( @@ -387,8 +386,7 @@ class _NotificationPreferencesScreenState } on Object { if (mounted) { setState( - () => _message = - 'Les préférences n’ont pas été confirmées. Réessayez lorsque Makolo est joignable.', + () => _message = 'Les préférences n’ont pas été confirmées. Réessayez lorsque Makolo est joignable.', ); } } finally { diff --git a/mobile/lib/platform/maps/makolo_map_view.dart b/mobile/lib/platform/maps/makolo_map_view.dart index 7cf344e3d..2bb0ae42f 100644 --- a/mobile/lib/platform/maps/makolo_map_view.dart +++ b/mobile/lib/platform/maps/makolo_map_view.dart @@ -65,12 +65,11 @@ class MakoloMapView extends StatelessWidget { enum MakoloMapRuntimeState { disabled, loading, ready, error } -typedef MakoloMapFallbackBuilder = - Widget Function( - BuildContext context, - MakoloMapRuntimeState state, - VoidCallback retry, - ); +typedef MakoloMapFallbackBuilder = Widget Function( + BuildContext context, + MakoloMapRuntimeState state, + VoidCallback retry, +); class ConfiguredMakoloMapView extends StatefulWidget { const ConfiguredMakoloMapView({ diff --git a/mobile/lib/platform/notifications/push_signal_processor.dart b/mobile/lib/platform/notifications/push_signal_processor.dart index 6d9acba77..b0cf2a434 100644 --- a/mobile/lib/platform/notifications/push_signal_processor.dart +++ b/mobile/lib/platform/notifications/push_signal_processor.dart @@ -8,8 +8,9 @@ class NormalizedPushSignal { final StructuredDestination? destination; } -typedef PushSignalNormalizer = - Future Function(PushSignal signal); +typedef PushSignalNormalizer = Future Function( + PushSignal signal, +); typedef PushSourceInvalidator = Future Function(String sourceKey); typedef PushOwnerRefresh = Future Function(Set sourceKeys); diff --git a/mobile/lib/presentation/mps/mps_native_renderer.dart b/mobile/lib/presentation/mps/mps_native_renderer.dart index 50a6e58fd..cab02d9f5 100644 --- a/mobile/lib/presentation/mps/mps_native_renderer.dart +++ b/mobile/lib/presentation/mps/mps_native_renderer.dart @@ -87,10 +87,8 @@ class MpsNativeRenderer extends StatelessWidget { return Text( _value(props['value']), style: level <= 1 - ? Theme.of(context).textTheme.headlineMedium?.copyWith( - color: palette.text, - fontWeight: FontWeight.w800, - ) + ? Theme.of(context).textTheme.headlineMedium + ?.copyWith(color: palette.text, fontWeight: FontWeight.w800) : Theme.of(context).textTheme.titleLarge?.copyWith( color: palette.text, fontWeight: FontWeight.w700, @@ -99,9 +97,8 @@ class MpsNativeRenderer extends StatelessWidget { case 'Subheading': return Text( _value(props['value']), - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: palette.muted), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: palette.muted), ); case 'Text': case 'Footer': @@ -138,9 +135,8 @@ class MpsNativeRenderer extends StatelessWidget { ? const SizedBox.shrink() : Text( value, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: palette.text), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: palette.text), ); case 'AccessSummary': final access = mpsMap(package.artifact.context['access']); @@ -252,9 +248,8 @@ class _InfoBlock extends StatelessWidget { padding: const EdgeInsets.only(bottom: MakoloSpacing.xs), child: Text( line, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: palette.text), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.text), ), ), ], diff --git a/mobile/lib/runtime/firebase_runtime_bootstrap.dart b/mobile/lib/runtime/firebase_runtime_bootstrap.dart index 25a384d04..eec1de5a3 100644 --- a/mobile/lib/runtime/firebase_runtime_bootstrap.dart +++ b/mobile/lib/runtime/firebase_runtime_bootstrap.dart @@ -24,8 +24,9 @@ class PluginFirebaseRuntime implements FirebaseRuntime { FirebaseMessaging get messaging => FirebaseMessaging.instance; } -typedef BackgroundMessageRegistrar = - void Function(Future Function(RemoteMessage) handler); +typedef BackgroundMessageRegistrar = void Function( + Future Function(RemoteMessage) handler, +); class FirebaseRuntimeBootstrap { FirebaseRuntimeBootstrap({ diff --git a/mobile/lib/runtime/observability_bootstrap.dart b/mobile/lib/runtime/observability_bootstrap.dart index 3a54b9331..1b1e4a4c9 100644 --- a/mobile/lib/runtime/observability_bootstrap.dart +++ b/mobile/lib/runtime/observability_bootstrap.dart @@ -4,8 +4,11 @@ import '../app/environment.dart'; import '../observability/observability.dart'; import 'runtime_service_status.dart'; -typedef SentryInitializer = - Future Function(String dsn, String environment, String? release); +typedef SentryInitializer = Future Function( + String dsn, + String environment, + String? release, +); Future _initializeSentry( String dsn, diff --git a/mobile/lib/sync/outbox/outbox_processor.dart b/mobile/lib/sync/outbox/outbox_processor.dart index b9b5526d1..54bf9af7c 100644 --- a/mobile/lib/sync/outbox/outbox_processor.dart +++ b/mobile/lib/sync/outbox/outbox_processor.dart @@ -9,11 +9,13 @@ enum OutboxResolution { failed, } -typedef OutboxHandler = - Future Function(OutboxOperation operation); +typedef OutboxHandler = Future Function( + OutboxOperation operation, +); -typedef OutboxReconciler = - Future Function(OutboxOperation operation); +typedef OutboxReconciler = Future Function( + OutboxOperation operation, +); class OutboxProcessor { OutboxProcessor({ diff --git a/mobile/lib/sync/sync_source.dart b/mobile/lib/sync/sync_source.dart index a33710aaa..79384332c 100644 --- a/mobile/lib/sync/sync_source.dart +++ b/mobile/lib/sync/sync_source.dart @@ -113,11 +113,10 @@ class SyncApplyContext { } typedef SyncSourceParser = AcquiredProjection Function(ApiResponse response); -typedef SyncSourceApplier = - Future Function( - SyncApplyContext context, - AcquiredProjection projection, - ); +typedef SyncSourceApplier = Future Function( + SyncApplyContext context, + AcquiredProjection projection, +); class SyncSourceDefinition { const SyncSourceDefinition({ diff --git a/mobile/test/actor_context_stabilization_test.dart b/mobile/test/actor_context_stabilization_test.dart index 2d55c5083..c445bdefc 100644 --- a/mobile/test/actor_context_stabilization_test.dart +++ b/mobile/test/actor_context_stabilization_test.dart @@ -119,37 +119,34 @@ void main() { }, ); - test( - 'revocation still falls back to Personal when the same Space remains active', - () async { - final store = _DelayedActorStore(); - final controller = await ActorContextController.restore( - profileId: 'profile-a', - store: store, - ); - addTearDown(controller.dispose); - - final space = SpaceActorIdentity(id: 'space-a', slug: 'old-slug'); - await controller.selectSpace(space); - - final validationStarted = Completer(); - final authoritativeResult = Completer(); - final validation = controller.revalidateSpaceContext((candidate) { - expect(candidate.id, 'space-a'); - validationStarted.complete(); - return authoritativeResult.future; - }); - - await validationStarted.future; - await controller.selectSpace( - SpaceActorIdentity(id: 'space-a', slug: 'new-slug'), - perspective: ActorPerspective.opaque('finance'), - ); - authoritativeResult.complete(false); - - expect(await validation, isFalse); - expect(controller.value, const PersonalActorContext()); - expect(store.values['profile-a'], const PersonalActorContext()); - }, - ); + test('revocation still falls back to Personal when the same Space remains active', () async { + final store = _DelayedActorStore(); + final controller = await ActorContextController.restore( + profileId: 'profile-a', + store: store, + ); + addTearDown(controller.dispose); + + final space = SpaceActorIdentity(id: 'space-a', slug: 'old-slug'); + await controller.selectSpace(space); + + final validationStarted = Completer(); + final authoritativeResult = Completer(); + final validation = controller.revalidateSpaceContext((candidate) { + expect(candidate.id, 'space-a'); + validationStarted.complete(); + return authoritativeResult.future; + }); + + await validationStarted.future; + await controller.selectSpace( + SpaceActorIdentity(id: 'space-a', slug: 'new-slug'), + perspective: ActorPerspective.opaque('finance'), + ); + authoritativeResult.complete(false); + + expect(await validation, isFalse); + expect(controller.value, const PersonalActorContext()); + expect(store.values['profile-a'], const PersonalActorContext()); + }); } diff --git a/mobile/test/api_client_test.dart b/mobile/test/api_client_test.dart index 555b7c251..d6cbd343e 100644 --- a/mobile/test/api_client_test.dart +++ b/mobile/test/api_client_test.dart @@ -11,11 +11,10 @@ import 'package:makolo_mobile/network/makolo_api_client.dart'; import 'fakes.dart'; -typedef RequestHandler = - Future Function( - RequestOptions options, - Stream? requestStream, - ); +typedef RequestHandler = Future Function( + RequestOptions options, + Stream? requestStream, +); class TestHttpAdapter implements HttpClientAdapter { TestHttpAdapter(this.handler); diff --git a/mobile/test/app_shell_test.dart b/mobile/test/app_shell_test.dart index b53131040..1184969cb 100644 --- a/mobile/test/app_shell_test.dart +++ b/mobile/test/app_shell_test.dart @@ -109,9 +109,8 @@ Future _pumpRouter( } if (textScale != 1) { result = MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), child: result, ); } diff --git a/mobile/test/ar_access_real_world_action_test.dart b/mobile/test/ar_access_real_world_action_test.dart index 16acafe4c..05fd0c0e3 100644 --- a/mobile/test/ar_access_real_world_action_test.dart +++ b/mobile/test/ar_access_real_world_action_test.dart @@ -467,27 +467,24 @@ void main() { expect(openedLive, isFalse); }); - test( - 'credential presentation requires the owner capability even when summary says presentable', - () { - final projection = StoredProjection( - kind: DayOfRepository.projectionKind, - resourceKey: 'occurrence-1', - schemaVersion: 1, - payload: dayOfPayload(presentCredentialCapability: false), - receivedAt: DateTime.utc(2026, 9, 30, 15), - ); + test('credential presentation requires the owner capability even when summary says presentable', () { + final projection = StoredProjection( + kind: DayOfRepository.projectionKind, + resourceKey: 'occurrence-1', + schemaVersion: 1, + payload: dayOfPayload(presentCredentialCapability: false), + receivedAt: DateTime.utc(2026, 9, 30, 15), + ); - final presentation = const DayOfSelector().select( - projection: projection, - source: DayOfSourceState.unknown, - now: DateTime.utc(2026, 9, 30, 15, 5), - ); + final presentation = const DayOfSelector().select( + projection: projection, + source: DayOfSourceState.unknown, + now: DateTime.utc(2026, 9, 30, 15, 5), + ); - expect(presentation.accesses.single.canPresentCredential, isFalse); - expect(presentation.canOpenLive, isTrue); - }, - ); + expect(presentation.accesses.single.canPresentCredential, isFalse); + expect(presentation.canOpenLive, isTrue); + }); test('owner 404 invalidates only the keyed Access snapshot', () async { final tokens = MemoryTokenStore( diff --git a/mobile/test/discovery_shell_contract_test.dart b/mobile/test/discovery_shell_contract_test.dart index 556a41971..76d20897d 100644 --- a/mobile/test/discovery_shell_contract_test.dart +++ b/mobile/test/discovery_shell_contract_test.dart @@ -4,9 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('Discover root has no nested toolbar or permanent search field', () { - final source = File( - 'lib/features/discovery/discovery_screens.dart', - ).readAsStringSync(); + final source = File('lib/features/discovery/discovery_screens.dart') + .readAsStringSync(); final rootEnd = source.indexOf('class DiscoverySearchScreen'); final root = source.substring(0, rootEnd); @@ -24,15 +23,13 @@ void main() { test( 'Discover map uses the configured MapLibre runtime from the same field', () { - final screens = File( - 'lib/features/discovery/discovery_screens.dart', - ).readAsStringSync(); + final screens = File('lib/features/discovery/discovery_screens.dart') + .readAsStringSync(); final presentation = File( 'lib/features/discovery/discovery_mature_view.dart', ).readAsStringSync(); - final routes = File( - 'lib/features/discovery/discovery_routes.dart', - ).readAsStringSync(); + final routes = File('lib/features/discovery/discovery_routes.dart') + .readAsStringSync(); expect(presentation, contains('ConfiguredMakoloMapView(')); expect(presentation, contains('MakoloSpatialFrame(')); diff --git a/mobile/test/file_media_core_test.dart b/mobile/test/file_media_core_test.dart index 15937d822..8eeb02153 100644 --- a/mobile/test/file_media_core_test.dart +++ b/mobile/test/file_media_core_test.dart @@ -101,9 +101,8 @@ void main() { ); final owner = ConfirmingOwnerTransfer(); - final receipt = await FileTransferCoordinator( - store, - ).uploadToOwner(fileId: 'capture-a', owner: owner); + final receipt = await FileTransferCoordinator(store) + .uploadToOwner(fileId: 'capture-a', owner: owner); expect(receipt.confirmed, isTrue); expect(receipt.remoteVersion, 'server-version-1'); diff --git a/mobile/test/file_transfer_continuity_test.dart b/mobile/test/file_transfer_continuity_test.dart index ee4770563..888bdc0b2 100644 --- a/mobile/test/file_transfer_continuity_test.dart +++ b/mobile/test/file_transfer_continuity_test.dart @@ -95,9 +95,8 @@ void main() { sensitivity: 'private', ); - final result = await FileTransferCoordinator( - store, - ).transferToOwner(fileId: 'file-2', owner: RecoverableOwner()); + final result = await FileTransferCoordinator(store) + .transferToOwner(fileId: 'file-2', owner: RecoverableOwner()); expect(result.state, FileTransferState.failedRecoverable); expect(result.errorCode, 'network_unreachable'); diff --git a/mobile/test/mps_repository_test.dart b/mobile/test/mps_repository_test.dart index d6f019e92..4ca5c7f62 100644 --- a/mobile/test/mps_repository_test.dart +++ b/mobile/test/mps_repository_test.dart @@ -69,50 +69,47 @@ void main() { expect(value.themeTokens['accent'], '#5232DB'); }); - test( - 'missing custom definitions degrade locally without losing artifact context', - () async { - final database = MakoloDatabase.memory(); - addTearDown(database.close); - final store = ProfileStore(database, 'profile-1'); - final repository = MpsPresentationRepository( - store: store, - profileId: 'profile-1', - ); + test('missing custom definitions degrade locally without losing artifact context', () async { + final database = MakoloDatabase.memory(); + addTearDown(database.close); + final store = ProfileStore(database, 'profile-1'); + final repository = MpsPresentationRepository( + store: store, + profileId: 'profile-1', + ); - await store.putProjection( - kind: mpsArtifactProjectionKind, - resourceKey: repository.activityArtifactKey('activity-1', 'invitation'), - schemaVersion: 1, - payload: const { - 'identity': { - 'kind': 'mps_artifact', - 'resource_key': 'activity:activity-1:invitation', - }, - 'purpose': 'invitation', - 'renderer': { - 'contract': 'mps.native.v1', - 'schema_version': 1, - 'minimum_renderer_version': 1, - }, - 'template': {'resource_key': '99', 'builtin': false}, - 'theme': {'resource_key': '88', 'builtin': false}, - 'context': { - 'activity': {'display_title': 'Offline Activity'}, - }, - 'capabilities': [], - 'links': {}, + await store.putProjection( + kind: mpsArtifactProjectionKind, + resourceKey: repository.activityArtifactKey('activity-1', 'invitation'), + schemaVersion: 1, + payload: const { + 'identity': { + 'kind': 'mps_artifact', + 'resource_key': 'activity:activity-1:invitation', + }, + 'purpose': 'invitation', + 'renderer': { + 'contract': 'mps.native.v1', + 'schema_version': 1, + 'minimum_renderer_version': 1, + }, + 'template': {'resource_key': '99', 'builtin': false}, + 'theme': {'resource_key': '88', 'builtin': false}, + 'context': { + 'activity': {'display_title': 'Offline Activity'}, }, - ); + 'capabilities': [], + 'links': {}, + }, + ); - final value = await repository.readActivityPackage( - 'activity-1', - 'invitation', - ); - expect(value, isNotNull); - expect(value!.usedFallback, isTrue); - expect(value.artifact.context['activity'], isNotNull); - expect(value.manifest, mpsEssentialManifest); - }, - ); + final value = await repository.readActivityPackage( + 'activity-1', + 'invitation', + ); + expect(value, isNotNull); + expect(value!.usedFallback, isTrue); + expect(value.artifact.context['activity'], isNotNull); + expect(value.manifest, mpsEssentialManifest); + }); } diff --git a/mobile/test/native_launch_contract_test.dart b/mobile/test/native_launch_contract_test.dart index 101afe238..bd3b577f5 100644 --- a/mobile/test/native_launch_contract_test.dart +++ b/mobile/test/native_launch_contract_test.dart @@ -4,18 +4,16 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('native launch uses Makolo indigo and the canonical white Mark', () { - final colors = File( - 'android/app/src/main/res/values/colors.xml', - ).readAsStringSync(); + final colors = File('android/app/src/main/res/values/colors.xml') + .readAsStringSync(); final launch = File( 'android/app/src/main/res/drawable/launch_background.xml', ).readAsStringSync(); final launchNight = File( 'android/app/src/main/res/drawable-night/launch_background.xml', ).readAsStringSync(); - final launch31 = File( - 'android/app/src/main/res/values-v31/styles.xml', - ).readAsStringSync(); + final launch31 = File('android/app/src/main/res/values-v31/styles.xml') + .readAsStringSync(); final launch31Night = File( 'android/app/src/main/res/values-night-v31/styles.xml', ).readAsStringSync(); @@ -37,9 +35,8 @@ void main() { test( 'Android host declares PAR-1B capabilities without storage permissions', () { - final manifest = File( - 'android/app/src/main/AndroidManifest.xml', - ).readAsStringSync(); + final manifest = File('android/app/src/main/AndroidManifest.xml') + .readAsStringSync(); expect(manifest, contains('android.permission.INTERNET')); expect(manifest, contains('android.permission.CAMERA')); @@ -65,9 +62,8 @@ void main() { ); test('Android host accepts share ingress without inventing app links', () { - final manifest = File( - 'android/app/src/main/AndroidManifest.xml', - ).readAsStringSync(); + final manifest = File('android/app/src/main/AndroidManifest.xml') + .readAsStringSync(); expect(manifest, contains('android.intent.action.SEND')); expect(manifest, contains('android.intent.action.SEND_MULTIPLE')); diff --git a/mobile/test/now_cross_surface_contract_test.dart b/mobile/test/now_cross_surface_contract_test.dart index 3efec78a6..16e97c66a 100644 --- a/mobile/test/now_cross_surface_contract_test.dart +++ b/mobile/test/now_cross_surface_contract_test.dart @@ -8,11 +8,9 @@ import 'package:makolo_mobile/features/now/now_selector.dart'; void main() { test('shared server semantics select S1-S5 identically on Flutter', () { - final fixture = - jsonDecode( - File('test/fixtures/now_s1_s5_contract.json').readAsStringSync(), - ) - as Map; + final fixture = jsonDecode( + File('test/fixtures/now_s1_s5_contract.json').readAsStringSync(), + ) as Map; final projection = StoredProjection( kind: 'personal.now', schemaVersion: 1, diff --git a/mobile/test/now_gallery_scenarios_test.dart b/mobile/test/now_gallery_scenarios_test.dart index 68de4b193..781e8f22e 100644 --- a/mobile/test/now_gallery_scenarios_test.dart +++ b/mobile/test/now_gallery_scenarios_test.dart @@ -55,16 +55,16 @@ void main() { }); test('S4 Gallery is a real explicit waiting response', () { - final situation = NowGalleryScenarios.select( - 'now-legitimate-waiting', - ).situations.single; + final situation = NowGalleryScenarios.select('now-legitimate-waiting') + .situations + .single; expect(topologyFor(situation), NowTopology.waiting); }); test('S5 Gallery is a justified composition', () { - final situation = NowGalleryScenarios.select( - 'now-multiple', - ).situations.single; + final situation = NowGalleryScenarios.select('now-multiple') + .situations + .single; expect(topologyFor(situation), NowTopology.composition); }); diff --git a/mobile/test/now_media_in_app_test.dart b/mobile/test/now_media_in_app_test.dart index 62d39afff..91eb146de 100644 --- a/mobile/test/now_media_in_app_test.dart +++ b/mobile/test/now_media_in_app_test.dart @@ -83,8 +83,7 @@ void main() { purpose: NowMediaPurpose.understand, kind: NowMediaKind.image, authorized: false, - url: - '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', ), ), ), @@ -124,8 +123,7 @@ void main() { target: NowMediaTarget.situation, purpose: NowMediaPurpose.prepare, kind: NowMediaKind.unknown, - url: - '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', authorized: true, ), ), diff --git a/mobile/test/now_media_viewer_test.dart b/mobile/test/now_media_viewer_test.dart index eef6e9195..3851134ad 100644 --- a/mobile/test/now_media_viewer_test.dart +++ b/mobile/test/now_media_viewer_test.dart @@ -4,8 +4,7 @@ import 'package:makolo_mobile/presentation/contracts/now_presentation.dart'; NowMediaBindingPresentation binding({ bool authorized = true, - String? url = - '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', + String? url = '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/', }) => NowMediaBindingPresentation( resourceRef: 'journey_artifact:11111111-2222-4333-8444-555555555555', target: NowMediaTarget.situation, @@ -34,8 +33,7 @@ void main() { expect( nowAuthorizedMediaPath( binding( - url: - '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=admin', + url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=admin', ), ), isNull, @@ -43,8 +41,7 @@ void main() { expect( nowAuthorizedMediaPath( binding( - url: - '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', + url: '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', ), ), isNull, @@ -57,8 +54,7 @@ void main() { purpose: NowMediaPurpose.prepare, kind: NowMediaKind.document, authorized: true, - url: - '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', + url: '/api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', ), ), 'api/v1/me/now/media/journey-artifacts/11111111-2222-4333-8444-555555555555/?view=text', @@ -74,8 +70,7 @@ void main() { purpose: NowMediaPurpose.prepare, kind: NowMediaKind.document, authorized: true, - mimeType: - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', url: '$original?view=text', downloadUrl: original, ); @@ -95,8 +90,7 @@ void main() { kind: NowMediaKind.document, authorized: true, url: '$original?view=text', - downloadUrl: - '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', + downloadUrl: '/api/v1/me/resources/versions/11111111-2222-4333-8444-555555555555/download/', ); expect(nowAuthorizedOriginalDownloadPath(hostile), isNull); }); diff --git a/mobile/test/share_ingress_test.dart b/mobile/test/share_ingress_test.dart index f48398dc5..822643c63 100644 --- a/mobile/test/share_ingress_test.dart +++ b/mobile/test/share_ingress_test.dart @@ -34,9 +34,8 @@ void main() { ], ); - final capture = await SharedPayloadCoordinator( - store, - ).capture(payload, fileIdFor: (_, _) => 'opaque-capture-1'); + final capture = await SharedPayloadCoordinator(store) + .capture(payload, fileIdFor: (_, _) => 'opaque-capture-1'); expect(capture.files.single.owner, 'InboundCapture'); expect(capture.files.single.path, startsWith(staging.path)); diff --git a/mobile/test/shell_recovery_persistence_test.dart b/mobile/test/shell_recovery_persistence_test.dart index bc3f8bdae..3ec8c9256 100644 --- a/mobile/test/shell_recovery_persistence_test.dart +++ b/mobile/test/shell_recovery_persistence_test.dart @@ -21,16 +21,13 @@ void main() { expect(await reopened.readShellLocation('profile-b'), '/me'); }); - test( - 'launch recovery uses persisted shell location without creating actor state', - () { - final recovery = SessionRecoveryController(); - recovery.restoreLaunchLocation('/space/us'); - - expect(recovery.initialLocation(), '/space/us'); - expect(recovery.awaitingAuthentication, isFalse); - }, - ); + test('launch recovery uses persisted shell location without creating actor state', () { + final recovery = SessionRecoveryController(); + recovery.restoreLaunchLocation('/space/us'); + + expect(recovery.initialLocation(), '/space/us'); + expect(recovery.awaitingAuthentication, isFalse); + }); test('account switch clears restored navigation context', () { final recovery = SessionRecoveryController(); diff --git a/mobile/test/space_actor_shell_test.dart b/mobile/test/space_actor_shell_test.dart index 7f927df67..a30624a1e 100644 --- a/mobile/test/space_actor_shell_test.dart +++ b/mobile/test/space_actor_shell_test.dart @@ -242,9 +242,8 @@ Future _pump( builder: (context, child) => textScale == 1 ? child! : MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: TextScaler.linear(textScale)), + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), ), diff --git a/mobile/test/sync_engine_test.dart b/mobile/test/sync_engine_test.dart index 27c55fcd5..43fa6b55d 100644 --- a/mobile/test/sync_engine_test.dart +++ b/mobile/test/sync_engine_test.dart @@ -122,9 +122,8 @@ void main() { 1, ); expect( - (await store.readProjection( - 'personal.now', - ))?.payload['actor_attention_state'], + (await store.readProjection('personal.now')) + ?.payload['actor_attention_state'], 'active', ); final interoperability = await store.readProjection( diff --git a/mobile/test/workmanager_scheduler_test.dart b/mobile/test/workmanager_scheduler_test.dart index 841a9ed74..135b2e177 100644 --- a/mobile/test/workmanager_scheduler_test.dart +++ b/mobile/test/workmanager_scheduler_test.dart @@ -4,14 +4,16 @@ import 'package:makolo_mobile/background/workmanager_scheduler.dart'; void main() { test('workmanager payload decodes to the Makolo background contract', () { - final task = - decodeWorkmanagerTask(WorkmanagerBackgroundScheduler.taskName, { - 'task_id': 'upload-1', - 'kind': 'upload', - 'profile_id': 'profile-a', - 'operation_id': 'operation-1', - 'requires_network': true, - }); + final task = decodeWorkmanagerTask( + WorkmanagerBackgroundScheduler.taskName, + { + 'task_id': 'upload-1', + 'kind': 'upload', + 'profile_id': 'profile-a', + 'operation_id': 'operation-1', + 'requires_network': true, + }, + ); expect(task?.id, 'upload-1'); expect(task?.kind, BackgroundTaskKind.upload); @@ -21,13 +23,15 @@ void main() { }); test('workmanager round-trip preserves network-independent maintenance', () { - final task = - decodeWorkmanagerTask(WorkmanagerBackgroundScheduler.taskName, { - 'task_id': 'cache-1', - 'kind': 'cacheMaintenance', - 'profile_id': 'profile-a', - 'requires_network': false, - }); + final task = decodeWorkmanagerTask( + WorkmanagerBackgroundScheduler.taskName, + { + 'task_id': 'cache-1', + 'kind': 'cacheMaintenance', + 'profile_id': 'profile-a', + 'requires_network': false, + }, + ); expect(task?.kind, BackgroundTaskKind.cacheMaintenance); expect(task?.requiresNetwork, isFalse); From 9d965d115e50d679f2051d125d8114c4cb980f15 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 11:57:43 +0200 Subject: [PATCH 12/16] feat(space): revalidate work selections and continue scoped collections --- .../mature-mobile-composition-checkpoints.md | 42 ++++++++++ organizations/api/space_work_projection.py | 77 ++++++++++++++++--- organizations/api/space_work_views.py | 12 ++- organizations/test_zs3_space_work_api.py | 50 ++++++++++++ 4 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 docs/operations/mature-mobile-composition-checkpoints.md diff --git a/docs/operations/mature-mobile-composition-checkpoints.md b/docs/operations/mature-mobile-composition-checkpoints.md new file mode 100644 index 000000000..510f1dc06 --- /dev/null +++ b/docs/operations/mature-mobile-composition-checkpoints.md @@ -0,0 +1,42 @@ +# Mature Mobile Experience — reprise Codex + +## Contrat de reprise + +- Objectif : fermer les écarts réels des contrats de préparation, depuis le checkpoint Antigravity, sans reconstruire les surfaces déjà modifiées. +- Owner : Codex, intégrateur unique. +- Base : `0366d81203ee06937427f055d4d31f905172f849`, neuf commits depuis `f2351b8b5db1ce362b7ddc6ca2951d9d79ce2538`. +- Branche : `feature/mature-mobile-composition` ; `origin/main` encore à la base au démarrage ; aucune PR ouverte (API GitHub). +- Surfaces : projections Organizations, Presentation/Flutter Space, routes propriétaires, tests ciblés, documentation de convergence. +- Interdits : modèles/tables/migrations, nouvelle autorité, données inventées, refonte du thème/sync, modification de main, push intermédiaire, merge automatique. +- Acceptation : profondeurs réellement accessibles et revalidées, provenance/limites visibles dans Piloter, relations distinctes, absence/fraîcheur/révocation honnêtes, contrats compatibles. +- Évidence : tests Django d'autorisation/sélection et Flutter de navigation/présentation/acteur ; formateur canonique, analyse, Goldens pertinents et gates finaux. +- Gate : validation locale puis push final unique, PR et CI verte ; aucun merge automatique. + +## A — Space Now / Découvrir + +`space_attention_projection.py`, `workspace_projection.py`, `space_work_projection.py` et les tests ZS6 distinguent visibilité du portefeuille et sélection attentionnelle. Les permissions autorisent la lecture des objets, mais ne définissent ni pertinence collective, ni pourquoi maintenant, ni prochain mouvement, ni admission de possibilités pour un Space. Le selector personnel Discovery ne porte pas ce contexte collectif. + +La composition du portefeuille Métier ne peut donc pas devenir une sélection Now/Discover par renommage. `selection.unavailable / no_safe_selection_contract` reste conservé, avec `items=[]` ; ce n'est pas une preuve de calme ou de champ vide. Le blocage précis est l'absence d'une politique de sélection collective établie, scopée par mandat/perspective, portant les raisons et les destinations autorisées. Aucun modèle ni politique métier n'est inventé pour la contourner. + +## B–D — écarts confirmés + +- Métier : les huit grammaires existent côté serveur ; les cartes enrichies d'Antigravity sont conservées. Les liens d'approfondissement restent absents de l'interface hors Jour J ; les collections bornées doivent afficher leur couverture. +- Relations : recherche/sélection propriétaire existent ; Flutter aplatit les owners et ne permet pas d'approfondissement. Ne pas fusionner deux relations vers un même Profil. +- Piloter : le serveur livre preuves, raison, incertitude, couverture et date ; Flutter ne montre que titre/résumé et clés techniques des mesures. +- Transversal : `SpaceRetrievalOwnerScreen` existe mais sa destination n'est pas montée dans le router ; fermeture nécessaire à la recherche/historique et aux handoffs Métier/Relations. + +La validation visuelle sur appareil n'est pas attestée par le transfert. + +## Checkpoints vérifiés + +- Projections Métier : sélection par identité avant les limites d'aperçu, avec les mêmes selectors et permissions ; pagination par section (20 éléments), offset borné ; refus des identités étrangères/malformées et des sections non visibles. Une lentille Activity n'expose pas les routes/véhicules collectifs. Les lieux et arrêts viennent des relations Geography/Transport existantes, avec prefetch. +- Profondeurs Flutter : destination propriétaire Space montée pour Métier, Recherche, Historique et Relations ; relecture serveur avec responsabilité courante. Les liens Activity et Jour J restent distincts. Présentation MPS d'une Activity revalidée avant utilisation ; échec explicite, sans ancien artifact affiché après refus. +- Continuation Métier : pages éphémères, sans nouveau store/sync ; retry au même offset, déduplication par identité propriétaire, contenu conservé sur erreur technique et retiré sur refus/changement d'acteur. +- Piloter : signaux avant mesures ; preuve, source, date, couverture bornée, raison et incertitude visibles. Zéro, inconnu et données insuffisantes restent distincts ; montants par devise seulement avec capability financière. Aucun calcul de tendance/causalité inventé. +- Relations : propriétaires conservés séparément, même si un Profil apparaît plusieurs fois ; aperçu et recherche distante distingués, résultats tardifs écartés, sélection revalidée au détail. +- Snapshot : SourceState, fraîcheur et date d'observation utilisés sur les racines et insights ; refus/révocation distincts du vide métier, snapshot expiré non exposé. +- Convergence : destination personnelle des commandes raccordée à l'écran propriétaire déjà présent ; corrections des contrôles null impossibles dans les commits repris, sans supprimer leurs comportements. + +Le premier checkpoint de formatage a appelé par erreur le binaire direct Dart 3.11.5 du SDK cassé `C:/src/flutter`, malgré son lanceur redirigé vers 3.13.3. L'appel corrigé passe par le SDK actif `C:/src/flutter_3473` (Flutter 3.47.3 / Dart 3.13.3). Les modifications de formatage étrangères au chantier ont été annulées par le vrai formateur, sans reset et sans modifier les neuf commits repris. + +Validation intermédiaire : 113 tests Django (Space ZS3/ZS4/ZS6, History/Search Space, contrats personnels Z1/Z2/Z4/Z5/Z6) et 485 tests Flutter hors Goldens réussis. Vérification de migrations : aucune modification détectée. Contrat OpenAPI : 12 opérations requises conservées ; 8 fixtures C0 valides. diff --git a/organizations/api/space_work_projection.py b/organizations/api/space_work_projection.py index 7734bc260..177db1d03 100644 --- a/organizations/api/space_work_projection.py +++ b/organizations/api/space_work_projection.py @@ -5,6 +5,7 @@ from django.utils import timezone from activities.models import Activity, ActivityStatus, Occurrence, OccurrenceStatus +from activities.selectors import primary_place_for_occurrence from authorization.constants import PermissionCode from authorization.models import AuthorityScope from authorization.selectors import ( @@ -106,6 +107,7 @@ def _occurrence_item(occurrence, caps): is_departure = hasattr(occurrence, "transport_departure") operation_visible = occurrence.activity_id in caps["operations"] current_day_of = operation_visible and occurrence.is_ongoing + place = primary_place_for_occurrence(occurrence) links = { "detail": f"/api/v1/occurrences/{occurrence.pk}/", "activity": f"/api/v1/activities/{occurrence.activity_id}/", @@ -135,6 +137,7 @@ def _occurrence_item(occurrence, caps): }, "state": occurrence.status, "context": { + "place": {"id": str(place.pk), "name": place.name} if place else None, "activity": { "id": str(occurrence.activity_id), "title": occurrence.activity.title, @@ -242,7 +245,14 @@ def _route_item(route): "summary": None, "timing": {}, "state": "active" if route.active else "inactive", - "context": {}, + "context": { + "stops": [ + {"id": str(stop.place_id), "name": stop.place.name, + "position": stop.position, "boarding_allowed": stop.boarding_allowed, + "alighting_allowed": stop.alighting_allowed} + for stop in route.stops.all() + ], + }, "links": {}, "capabilities": ["view"], } @@ -323,6 +333,12 @@ def _empty_sections(*, space, include_offers=False, include_transport=False, inc def _append(section, item): + if not section.get("_enabled", True): + return + seen = section.get("_seen", 0) + section["_seen"] = seen + 1 + if seen < section.get("_offset", 0): + return if len(section["items"]) < PREVIEW_LIMIT: section["items"].append(item) else: @@ -381,7 +397,22 @@ def _journey_section(journey): return None -def build_space_work_projection(*, profile, space, responsibility_key=None): +def _selected(queryset, kind, source_kind, source_id): + """Restrict an owner read to one identity before applying preview limits.""" + if not source_kind and not source_id: + return queryset + if source_kind != kind or not source_id: + return queryset.none() + try: + return queryset.filter(pk=source_id) + except (ValueError, DjangoValidationError): + return queryset.none() + + +def build_space_work_projection( + *, profile, space, responsibility_key=None, source_kind=None, source_id=None, + section_key=None, offset=0 +): visible_ids = _visible_activity_ids(profile, space) lens_ids = _activity_scope_from_responsibility( profile, space, responsibility_key @@ -396,6 +427,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): direct_portfolio = _space_has_activity_portfolio_access(profile, space) transport_visible = ( direct_portfolio + and lens_ids is None and "transport" in operational_footprint_for_space(space).signals ) commerce_visible = bool(visible_ids & caps["commerce"]) @@ -409,10 +441,16 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): include_transport=transport_visible, include_requests=education_visible or service_visible, ) + if section_key and section_key not in sections: + return None + for key, section in sections.items(): + section["_enabled"] = not section_key or key == section_key + section["_offset"] = offset if section_key else 0 now = timezone.now() today = timezone.localdate() activities = Activity.objects.filter(space=space, pk__in=visible_ids).order_by("title", "pk") + activities = _selected(activities, "activity", source_kind, source_id) for activity in activities: if activity.status == ActivityStatus.DRAFT: target = "preparation" @@ -427,8 +465,10 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): occurrences = ( Occurrence.objects.filter(activity__space=space, activity_id__in=visible_ids) .select_related("activity", "transport_departure") + .prefetch_related("place_links__place") .order_by("start_date", "start_time", "pk") ) + occurrences = _selected(occurrences, "occurrence", source_kind, source_id) for occurrence in occurrences: if occurrence.status == OccurrenceStatus.DRAFT: target = "preparation" @@ -448,11 +488,12 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _occurrence_item(occurrence, caps)) commerce_activity_ids = visible_ids & caps["commerce"] - for offer in ( + offers = ( Offer.objects.filter(activity_id__in=commerce_activity_ids) .select_related("activity") .order_by("activity_id", "name", "pk") - ): + ) + for offer in _selected(offers, "offer", source_kind, source_id): if offer.status == OfferStatus.DRAFT: target = "preparation" elif offer.status == OfferStatus.ACTIVE: @@ -472,6 +513,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): .select_related("journey__activity") .order_by("-created_at", "pk") ) + orders = _selected(orders, "commerce_order", source_kind, source_id) for order in orders: if order.status == CommerceOrderStatus.DRAFT: target = "preparation" @@ -488,7 +530,7 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _order_item(order, caps)) if education_ids: - for journey in ( + journeys = ( Journey.objects.filter( activity_id__in=education_ids, workflow=WorkflowKind.REGISTRATION, @@ -496,7 +538,8 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): .select_related("activity") .prefetch_related("blockers") .order_by("-created_at", "pk") - ): + ) + for journey in _selected(journeys, "journey", source_kind, source_id): if ( education_visible and journey.status in { @@ -511,12 +554,13 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _journey_item(journey, caps)) if service_ids: - for journey in ( + journeys = ( service_journeys_visible_to(profile) .filter(activity_id__in=service_ids) .select_related("activity") .prefetch_related("blockers") - ): + ) + for journey in _selected(journeys, "journey", source_kind, source_id): if ( service_visible and journey.status in { @@ -531,9 +575,11 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): _append(sections[target], _journey_item(journey, caps)) if transport_visible: - for route in TransportRoute.objects.filter(space=space).order_by("name", "pk"): + routes = TransportRoute.objects.filter(space=space).prefetch_related("stops__place").order_by("name", "pk") + for route in _selected(routes, "transport_route", source_kind, source_id): _append(sections["routes"], _route_item(route)) - for vehicle in Vehicle.objects.filter(space=space).order_by("label", "pk"): + vehicles = Vehicle.objects.filter(space=space).order_by("label", "pk") + for vehicle in _selected(vehicles, "vehicle", source_kind, source_id): _append(sections["vehicles"], _vehicle_item(vehicle)) footprint = ( @@ -541,6 +587,12 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): if direct_portfolio else [] ) + for section in sections.values(): + section.pop("_enabled", None) + section.pop("_offset", None) + section.pop("_seen", None) + if section_key: + sections = {section_key: sections[section_key]} return { "space": { "id": str(space.pk), @@ -562,6 +614,11 @@ def build_space_work_projection(*, profile, space, responsibility_key=None): "composition_state": "established", "coverage_state": "established", "sections": sections, + "page": {"offset": offset if section_key else 0, "limit": PREVIEW_LIMIT}, + "selection": next( + (item for section in sections.values() for item in section["items"]), + None, + ) if source_kind or source_id else None, "links": { "workspace": f"/api/v1/organizations/workspaces/{space.slug}/", }, diff --git a/organizations/api/space_work_views.py b/organizations/api/space_work_views.py index 861ef1523..9f1ccf91d 100644 --- a/organizations/api/space_work_views.py +++ b/organizations/api/space_work_views.py @@ -1,5 +1,5 @@ from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -19,10 +19,20 @@ def _space(self, request, slug): def get(self, request, slug): space = self._space(request, slug) + try: + offset = int(request.query_params.get("offset", "0")) + except ValueError: + raise ValidationError({"offset": "Décalage invalide."}) + if not 0 <= offset <= 10000: + raise ValidationError({"offset": "Décalage hors limites."}) payload = build_space_work_projection( profile=request.user, space=space, responsibility_key=(request.query_params.get("responsibility") or "").strip() or None, + source_kind=(request.query_params.get("kind") or "").strip()[:40] or None, + source_id=(request.query_params.get("id") or "").strip()[:80] or None, + section_key=(request.query_params.get("section") or "").strip()[:40] or None, + offset=offset, ) if payload is None: raise NotFound() diff --git a/organizations/test_zs3_space_work_api.py b/organizations/test_zs3_space_work_api.py index 28b770636..552cb3398 100644 --- a/organizations/test_zs3_space_work_api.py +++ b/organizations/test_zs3_space_work_api.py @@ -69,6 +69,56 @@ def _activity(self, title="Activité ZS3", **kwargs): **kwargs, ) + def test_owner_selection_revalidates_scope_and_works_beyond_preview(self): + self.client.force_authenticate(self.owner) + for index in range(21): + self._activity(f"A {index:02}") + selected = self._activity("Z selected") + response = self.client.get(self.url, {"kind": "activity", "id": str(selected.pk)}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["selection"]["source"]["id"], str(selected.pk)) + foreign = Organization.objects.create(name="Foreign", slug="foreign-work", created_by=self.owner) + hidden = Activity.objects.create(title="Hidden", space=foreign, created_by=self.owner) + for kind, identity in (("activity", hidden.pk), ("activity", "bad-id"), ("unknown", selected.pk)): + denied = self.client.get(self.url, {"kind": kind, "id": str(identity)}) + self.assertIsNone(denied.data["selection"]) + self.assertNotIn("Hidden", str(denied.data)) + + def test_work_section_continuation_does_not_repeat_preview_or_cross_owner(self): + self.client.force_authenticate(self.owner) + expected = [self._activity(f"A {index:02}") for index in range(23)] + first = self.client.get(self.url, {"section": "preparation", "offset": "0"}) + second = self.client.get(self.url, {"section": "preparation", "offset": "20"}) + self.assertEqual(set(second.data["sections"]), {"preparation"}) + preview = first.data["sections"]["preparation"] + continuation = second.data["sections"]["preparation"] + self.assertTrue(preview["has_more"]) + self.assertFalse(continuation["has_more"]) + identities = [item["source"]["id"] for item in preview["items"] + continuation["items"]] + self.assertEqual(identities, [str(activity.pk) for activity in expected]) + self.assertNotIn("_offset", str(second.data)) + self.assertEqual(self.client.get(self.url, {"offset": "-1"}).status_code, 400) + self.assertEqual(self.client.get(self.url, {"offset": "bad"}).status_code, 400) + self.assertEqual(self.client.get(self.url, {"section": "not-an-owner"}).status_code, 404) + + def test_activity_lens_does_not_expose_transport_structure_or_foreign_activity(self): + self.client.force_authenticate(self.owner) + self.space.archetype = SpaceArchetype.TRANSPORT_OPERATOR + self.space.save(update_fields=["archetype", "updated_at"]) + activity = self._activity("Scoped activity") + hidden = self._activity("Other activity") + mandate = grant_activity_role( + profile=self.owner, activity=activity, + role_code=SystemRoleCode.ACTIVITY_MANAGER, granted_by=self.owner, + ) + route = TransportRoute.objects.create(space=self.space, name="Route privée") + params = {"responsibility": f"mandate:{mandate.pk}"} + response = self.client.get(self.url, params) + self.assertNotIn("routes", response.data["sections"]) + for kind, identity in (("transport_route", route.pk), ("activity", hidden.pk)): + selected = self.client.get(self.url, {**params, "kind": kind, "id": str(identity)}) + self.assertIsNone(selected.data["selection"]) + def test_auth_and_minimal_disclosure_boundaries(self): self.assertEqual(self.client.get(self.url).status_code, 401) From bf3b331f3af464dc60fb1a03f20b9a877f6768c0 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 12:15:42 +0200 Subject: [PATCH 13/16] feat(mobile): complete scoped Space depths insights and MPS media --- mobile/lib/app/router.dart | 48 +++ .../personal_owner_depth_screen.dart | 161 ++++--- .../space_retrieval_owner_screen.dart | 221 +++++++--- .../discovery/discovery_mature_view.dart | 10 +- mobile/lib/features/me/me_screen.dart | 7 +- mobile/lib/features/now/now_screen.dart | 2 +- .../features/space/space_insight_screen.dart | 194 ++++++--- .../space/space_insight_surfaces.dart | 200 +++++++++ .../features/space/space_shell_routes.dart | 99 ++++- .../space/space_work_collection_screen.dart | 203 +++++++++ .../features/space/space_work_surface.dart | 75 +++- .../contracts/space_presentation.dart | 105 +++++ .../presentation/mps/mps_native_renderer.dart | 93 +++++ mobile/test/mps_native_renderer_test.dart | 87 ++++ .../test/space_mature_composition_test.dart | 394 ++++++++++++++++++ organizations/api/space_work_projection.py | 4 + organizations/test_zs3_space_work_api.py | 2 + 17 files changed, 1694 insertions(+), 211 deletions(-) create mode 100644 mobile/lib/features/space/space_insight_surfaces.dart create mode 100644 mobile/lib/features/space/space_work_collection_screen.dart create mode 100644 mobile/lib/presentation/contracts/space_presentation.dart create mode 100644 mobile/test/space_mature_composition_test.dart diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart index 7645c9c56..49d8137f7 100644 --- a/mobile/lib/app/router.dart +++ b/mobile/lib/app/router.dart @@ -1,4 +1,6 @@ import '../features/continuity/transverse_retrieval_screen.dart'; +import '../features/continuity/space_retrieval_owner_screen.dart'; +import '../features/continuity/personal_owner_depth_screen.dart'; import 'dart:async'; @@ -28,6 +30,7 @@ import '../features/settings/change_password_screen.dart'; import '../features/settings/settings_screen.dart'; import '../features/space/space_shell_routes.dart'; import '../features/space/space_insight_screen.dart'; +import '../features/space/space_work_collection_screen.dart'; import '../features/space/space_repository.dart'; import '../features/space/space_occurrence_screen.dart'; import '../navigation/destination.dart'; @@ -219,6 +222,51 @@ GoRouter createMakoloRouter( ...markRoutes(runtime), ...continuityRoutes(runtime), ...notificationRoutes(runtime), + GoRoute( + path: '/space/:slug/work/:section', + builder: (context, state) { + final actor = runtime.actorContext?.value; + if (actor is! SpaceActorContext || + actor.space.slug != state.pathParameters['slug']) { + return const MakoloSecondaryScreen( + title: 'Métier', + message: 'Choisissez l’Espace autorisé pour cette collection.', + ); + } + return SpaceWorkCollectionScreen( + runtime: runtime, + actor: actor, + section: state.pathParameters['section']!, + ); + }, + ), + GoRoute( + path: '/me/orders/:id', + builder: (context, state) => PersonalOwnerDepthScreen( + runtime: runtime, + kind: 'commerce_order', + id: state.pathParameters['id']!, + ), + ), + GoRoute( + path: '/space/:slug/retrieval/:kind/:id', + builder: (context, state) { + final actor = runtime.actorContext?.value; + if (actor is! SpaceActorContext || + actor.space.slug != state.pathParameters['slug']) { + return const MakoloSecondaryScreen( + title: 'Détail de mon Espace', + message: 'Choisissez l’Espace autorisé pour ce détail.', + ); + } + return SpaceRetrievalOwnerScreen( + runtime: runtime, + spaceActor: actor, + kind: state.pathParameters['kind']!, + id: state.pathParameters['id']!, + ); + }, + ), GoRoute( path: '/search', builder: (context, state) => TransverseRetrievalScreen( diff --git a/mobile/lib/features/continuity/personal_owner_depth_screen.dart b/mobile/lib/features/continuity/personal_owner_depth_screen.dart index 4b80892ec..783d20148 100644 --- a/mobile/lib/features/continuity/personal_owner_depth_screen.dart +++ b/mobile/lib/features/continuity/personal_owner_depth_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../../app/runtime/app_runtime.dart'; +import '../../app/runtime/actor_context.dart'; +import '../../design/makolo_patterns.dart'; +import '../../presentation/contracts/space_presentation.dart'; /// Directly consults the canonical personal owner projection before display. class PersonalOwnerDepthScreen extends StatefulWidget { @@ -22,16 +25,38 @@ class PersonalOwnerDepthScreen extends StatefulWidget { class _PersonalOwnerDepthScreenState extends State { late Future> _detail; + bool get _actorValid => + widget.runtime.actorContext?.value is! SpaceActorContext; @override void initState() { super.initState(); + widget.runtime.actorContext?.addListener(_onActorChanged); _detail = _load(); } + void _onActorChanged() { + if (!mounted) return; + setState( + () => _detail = _actorValid + ? _load() + : Future.value(const {}), + ); + } + + @override + void dispose() { + widget.runtime.actorContext?.removeListener(_onActorChanged); + super.dispose(); + } + @override void didUpdateWidget(covariant PersonalOwnerDepthScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(_onActorChanged); + widget.runtime.actorContext?.addListener(_onActorChanged); + } if (oldWidget.runtime != widget.runtime || oldWidget.id != widget.id || oldWidget.kind != widget.kind) { @@ -40,6 +65,8 @@ class _PersonalOwnerDepthScreenState extends State { } Future> _load() async { + if (!_actorValid) return const {}; + final runtime = widget.runtime; final api = widget.runtime.api; if (api == null) { throw StateError('Owner service unavailable'); @@ -51,6 +78,9 @@ class _PersonalOwnerDepthScreenState extends State { _ => throw const FormatException('Unknown owner kind'), }; final response = await api.get('api/v1/me/$segment'); + if (!_actorValid || runtime != widget.runtime) { + throw StateError('Owner context changed'); + } final envelope = response.jsonObject(); final raw = envelope['data']; if (raw is! Map) throw const FormatException('Invalid owner response'); @@ -75,70 +105,75 @@ class _PersonalOwnerDepthScreenState extends State { : 'Mon groupe', ), ), - body: FutureBuilder>( - future: _detail, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (!snapshot.hasData) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Cette information n’est pas disponible ' - 'avec votre accès actuel.', - ), - TextButton( - onPressed: () => setState(() => _detail = _load()), - child: const Text('Réessayer'), - ), - ], + body: !_actorValid + ? const Center( + child: Text( + 'Revenez à votre contexte personnel pour cette commande.', ), - ); - } - final data = snapshot.data!; - final title = document || order ? data['title'] : data['name']; - final details = [ - if (document) data['asset_kind_label']?.toString() ?? 'Document', - if (!document && data['description'] is String) - data['description'] as String, - if (data['status'] is String) data['status'] as String, - if (order && data['activity'] is Map) - (data['activity'] as Map)['title']?.toString() ?? '', - ]; - return ListView( - padding: const EdgeInsets.all(24), - children: [ - Text( - title?.toString() ?? 'Élément', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - for (final detail in details) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text(detail), - ), - if (document) ...[ - const SizedBox(height: 20), - const Text( - 'Un document conservé ne satisfait pas ' - 'automatiquement une exigence.', - ), - ], - if (!document && data['members'] is Map) ...[ - const SizedBox(height: 16), - Text( - 'Membres actifs : ' - '${(data['members'] as Map)['active_count'] ?? 'Non précisé'}', - ), - ], - ], - ); - }, - ), + ) + : FutureBuilder>( + future: _detail, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (!snapshot.hasData) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Cette information n’est pas disponible ' + 'avec votre accès actuel.', + ), + TextButton( + onPressed: () => setState(() => _detail = _load()), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + final data = snapshot.data!; + final title = document || order ? data['title'] : data['name']; + final details = [ + if (document) + data['asset_kind_label']?.toString() ?? 'Document', + if (!document && data['description'] is String) + data['description'] as String, + if (data['status'] is String) + spaceStateLabel(data['status'] as String), + if (order && data['activity'] is Map) + (data['activity'] as Map)['title']?.toString() ?? '', + ]; + return ListView( + padding: const EdgeInsets.all(24), + children: [ + MakoloDetailHeader(title: title?.toString() ?? 'Élément'), + const SizedBox(height: 16), + for (final detail in details) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(detail), + ), + if (document) ...[ + const SizedBox(height: 20), + const Text( + 'Un document conservé ne satisfait pas ' + 'automatiquement une exigence.', + ), + ], + if (!document && data['members'] is Map) ...[ + const SizedBox(height: 16), + Text( + 'Membres actifs : ' + '${(data['members'] as Map)['active_count'] ?? 'Non précisé'}', + ), + ], + ], + ); + }, + ), ); } } diff --git a/mobile/lib/features/continuity/space_retrieval_owner_screen.dart b/mobile/lib/features/continuity/space_retrieval_owner_screen.dart index eb3085c6a..01a8d3f2d 100644 --- a/mobile/lib/features/continuity/space_retrieval_owner_screen.dart +++ b/mobile/lib/features/continuity/space_retrieval_owner_screen.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../app/runtime/actor_context.dart'; import '../../app/runtime/app_runtime.dart'; +import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import '../../presentation/mps/mps_models.dart'; +import '../../presentation/mps/mps_native_renderer.dart'; /// Owner-backed Space N3. A search result is never an authorization grant: /// every opening re-reads the source using the current account and actor. @@ -41,7 +47,9 @@ class _SpaceRetrievalOwnerScreenState extends State { if (!mounted) return; setState(() { // A return to this Space also forces owner revalidation. - _result = _readOwner(); + _result = _actorValid + ? _readOwner() + : Future.value(const {}); }); } @@ -77,7 +85,12 @@ class _SpaceRetrievalOwnerScreenState extends State { final kind = widget.kind; final id = Uri.encodeComponent(widget.id); final data = switch (kind) { - 'activity' || 'occurrence' => await _readActivityOwner(api, kind, id), + 'activity' || + 'occurrence' || + 'offer' || + 'journey' || + 'transport_route' || + 'vehicle' => await _readWorkOwner(api, kind), 'commerce_order' => await _readOrderOwner(api, id), 'team_member' || 'group' || @@ -90,59 +103,53 @@ class _SpaceRetrievalOwnerScreenState extends State { return data; } - Future> _readActivityOwner( - dynamic api, - String kind, - String id, - ) async { - final path = kind == 'activity' - ? 'api/v1/activities/$id/' - : 'api/v1/occurrences/$id/'; - final envelope = (await api.get(path)).jsonObject(); - final data = envelope['data']; - if (data is! Map) throw const FormatException('Missing owner data'); - final payload = Map.from(data); - final identity = payload['identity']; - if (identity is! Map || - identity['kind'] != kind || - identity['id'] != widget.id) { - throw const FormatException('Owner identity mismatch'); + Future> _readWorkOwner(dynamic api, String kind) async { + final perspective = widget.spaceActor.perspective; + final path = Uri( + path: '${_spacePath}work/', + queryParameters: { + 'kind': kind, + 'id': widget.id, + if (!perspective.isAll) 'responsibility': perspective.id!, + }, + ).toString(); + final payload = (await api.get(path)).jsonObject(); + final item = spaceMap(payload['selection']); + final source = spaceMap(item?['source']); + final space = spaceMap(payload['space']); + if (space?['id'] != widget.spaceActor.space.id || + source?['kind'] != kind || + source?['id'] != widget.id) { + throw const FormatException( + 'Owner unavailable in current responsibility', + ); } - Map ownerData = payload; - if (kind == 'occurrence') { - final activity = payload['activity']; - if (activity is! Map || activity['id'] == null) { - throw const FormatException('Occurrence activity is missing'); + MpsPresentationPackage? presentation; + var presentationUnavailable = false; + final mps = widget.runtime.mps; + if (kind == 'activity' && mps != null) { + try { + await mps.refreshActivity(widget.id, 'public_page'); + if (!_actorValid) throw StateError('Actor changed'); + presentation = await mps.readActivityPackage(widget.id, 'public_page'); + } on Object { + // Do not expose an older artifact after failed owner revalidation. + presentationUnavailable = true; } - final ownerEnvelope = (await api.get( - 'api/v1/activities/${Uri.encodeComponent(activity['id'].toString())}/', - )).jsonObject(); - if (ownerEnvelope['data'] is! Map) { - throw const FormatException('Activity owner unavailable'); - } - ownerData = ownerEnvelope['data'] as Map; - } - final owner = ownerData['owner']; - if (owner is! Map || - owner['kind'] != 'space' || - owner['id'] != widget.spaceActor.space.id) { - throw const FormatException('Result belongs to another actor'); } - final representation = payload['representation']; - final title = kind == 'activity' - ? (representation is Map ? representation['title'] : null) - : (payload['activity'] is Map - ? (payload['activity'] as Map)['title'] - : null); - final status = payload['state']; - final place = payload['place']; return { - 'title': title ?? 'Élément', - 'type': kind == 'activity' ? 'Activité' : 'Séance', - 'status': status is Map ? status['code'] : null, - 'summary': representation is Map ? representation['summary'] : null, - 'place': place is Map ? (place['name'] ?? place['locality']) : null, - 'timing': payload['timing'], + 'title': item!['title'], + 'type': payload['primary_business_label'], + 'status': item['state'], + 'summary': item['summary'], + 'timing': item['timing'], + 'context': item['context'], + 'presentation': presentation, + 'presentation_unavailable': presentationUnavailable, + 'continuity_facet': item['continuity_facet'], + 'day_of': + (item['capabilities'] is List && + (item['capabilities'] as List).contains('open_day_of')), }; } @@ -236,24 +243,118 @@ class _SpaceRetrievalOwnerScreenState extends State { ); } final data = snapshot.data!; + final itemContext = spaceMap(data['context']); + final activity = spaceMap(itemContext?['activity']); + final activityRoute = spaceOwnerRoute( + slug: widget.spaceActor.space.slug, + kind: 'activity', + id: spaceText(activity?['id']), + ); + final blockers = spaceMap( + data['continuity_facet'], + )?['blockers']; return ListView( padding: const EdgeInsets.all(24), children: [ - Text( - data['type']?.toString() ?? 'Élément', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - Text( - data['title']?.toString() ?? 'Détail', - style: Theme.of(context).textTheme.headlineSmall, + MakoloDetailHeader( + title: spaceText(data['title']) ?? 'Détail', + eyebrow: spaceText(data['type']), + subtitle: spaceText(data['summary']), + status: spaceText(data['status']) == null + ? null + : MakoloStatus( + label: spaceStateLabel(data['status'] as String), + ), ), - for (final key in ['status', 'summary', 'place', 'timing']) + for (final key in ['place']) if (data[key] != null) Padding( padding: const EdgeInsets.only(top: 14), child: Text(data[key].toString()), ), + for (final line in spaceTimingLines(data['timing'])) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(line), + ), + if (itemContext?['passenger_capacity'] is num) + Text( + '${itemContext!['passenger_capacity']} places dans le véhicule', + ), + if (spaceMap(itemContext?['pricing']) + case final Map pricing) + if (pricing['unit_price'] is String && + pricing['currency'] is String) + ListTile( + leading: const Icon(Icons.payments_outlined), + title: Text( + 'Prix unitaire : ${pricing['unit_price']} ${pricing['currency']}', + ), + ), + if (spaceText(spaceMap(itemContext?['place'])?['name']) + case final String place) + ListTile( + leading: const Icon(Icons.place_outlined), + title: Text(place), + ), + if (itemContext?['stops'] case final List stops) + for (final stop in stops) + if (spaceMap(stop) + case final Map place) + ListTile( + leading: const Icon(Icons.place_outlined), + title: Text(spaceText(place['name']) ?? 'Arrêt'), + subtitle: Text( + [ + if (place['boarding_allowed'] == true) + 'Embarquement', + if (place['alighting_allowed'] == true) + 'Descente', + ].join(' · '), + ), + ), + if (blockers is List && blockers.isNotEmpty) + MakoloAttentionBlock( + title: 'Points à examiner', + body: + '${blockers.length} blocage(s) actif(s) dans cette démarche.', + ), + if (activityRoute != null) + ListTile( + title: Text( + spaceText(activity?['title']) ?? 'Activité liée', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(activityRoute), + ), + if (data['day_of'] == true && widget.kind == 'occurrence') + FilledButton.icon( + onPressed: () => context.push( + '/space/occurrences/${Uri.encodeComponent(widget.id)}/day-of', + ), + icon: const Icon(Icons.play_circle_outline), + label: const Text('Jour J'), + ), + if (data['presentation'] + case final MpsPresentationPackage package) + if (spaceMap(package.artifact.context['editorial']) + ?.isNotEmpty == + true) + ExpansionTile( + title: const Text('Présentation'), + children: [ + MpsNativeRenderer( + package: package, + mediaBaseUri: widget.runtime.api?.baseUri, + mediaScopeKey: + '${widget.runtime.session?.profileId}:${widget.spaceActor.space.id}:${widget.spaceActor.perspective.id ?? 'all'}', + ), + ], + ), + if (data['presentation_unavailable'] == true) + const Text( + 'La présentation associée est indisponible. Actualisez pour réessayer.', + ), ], ); }, diff --git a/mobile/lib/features/discovery/discovery_mature_view.dart b/mobile/lib/features/discovery/discovery_mature_view.dart index 13d64d6e6..4a819a168 100644 --- a/mobile/lib/features/discovery/discovery_mature_view.dart +++ b/mobile/lib/features/discovery/discovery_mature_view.dart @@ -515,7 +515,7 @@ class _DiscoveryUnit extends StatelessWidget { if (item.availability != null || item.canSave || item.canUnsave || - item.savedState != null) ...[ + item.savedState == 'saved') ...[ const SizedBox(height: MakoloSpacing.md), Row( children: [ @@ -524,7 +524,7 @@ class _DiscoveryUnit extends StatelessWidget { const Spacer(), if (item.canSave || item.canUnsave || - item.savedState != null) + item.savedState == 'saved') Icon( item.savedState == 'saved' ? Icons.bookmark_rounded @@ -684,14 +684,16 @@ class _DiscoveryFocusPane extends StatelessWidget { if (item.availability != null || item.canSave || item.canUnsave || - item.savedState != null) ...[ + item.savedState == 'saved') ...[ const SizedBox(height: MakoloSpacing.md), Row( children: [ if (item.availability != null) MakoloStatus(label: item.availability!), const Spacer(), - if (item.canSave || item.canUnsave || item.savedState != null) + if (item.canSave || + item.canUnsave || + item.savedState == 'saved') Icon( item.savedState == 'saved' ? Icons.bookmark_rounded diff --git a/mobile/lib/features/me/me_screen.dart b/mobile/lib/features/me/me_screen.dart index 4c79b71b7..d531b6e47 100644 --- a/mobile/lib/features/me/me_screen.dart +++ b/mobile/lib/features/me/me_screen.dart @@ -474,9 +474,7 @@ class _MeTerritory extends StatelessWidget { 'Une vue de ce qui peut vous représenter selon le contexte.'; return MakoloCard( - onTap: firstItem == null || onSelectItem == null - ? null - : () => onSelectItem!(firstItem), + onTap: firstItem == null ? null : () => onSelectItem(firstItem), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -495,8 +493,7 @@ class _MeTerritory extends StatelessWidget { ], ), ), - if (firstItem != null && onSelectItem != null) - const Icon(Icons.chevron_right_rounded), + if (firstItem != null) const Icon(Icons.chevron_right_rounded), ], ), ); diff --git a/mobile/lib/features/now/now_screen.dart b/mobile/lib/features/now/now_screen.dart index ddc4c7430..e94bab63e 100644 --- a/mobile/lib/features/now/now_screen.dart +++ b/mobile/lib/features/now/now_screen.dart @@ -720,7 +720,7 @@ class _NowSecondarySituation extends StatelessWidget { semanticLabel: [ ?contextLabel, situation.meaning, - if (whyNow != null) whyNow, + ?whyNow, 'Ouvrir le détail', ].join('. '), child: Column( diff --git a/mobile/lib/features/space/space_insight_screen.dart b/mobile/lib/features/space/space_insight_screen.dart index 40d37bb96..a67de1bc2 100644 --- a/mobile/lib/features/space/space_insight_screen.dart +++ b/mobile/lib/features/space/space_insight_screen.dart @@ -1,11 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../app/runtime/actor_context.dart'; import '../../app/runtime/app_runtime.dart'; import '../../data/local/profile_store.dart'; +import '../../design/behavior_states.dart'; +import '../../design/surface_states.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import '../../presentation/projection_surface_adapter.dart'; +import '../../sync/freshness.dart'; import '../../sync/owner_source_state.dart'; +import 'space_insight_surfaces.dart'; import 'space_repository.dart'; class SpaceInsightScreen extends StatefulWidget { @@ -27,6 +34,7 @@ class _SpaceInsightScreenState extends State { Map? results; bool searchFailed = false; int searchVersion = 0; + bool refreshing = false; SpaceActorContext? get actor { final current = widget.runtime.actorContext?.value; @@ -47,12 +55,32 @@ class _SpaceInsightScreenState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant SpaceInsightScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(onActorChanged); + widget.runtime.actorContext?.addListener(onActorChanged); + } + if (oldWidget.runtime != widget.runtime || + oldWidget.surface != widget.surface) { + searchVersion++; + results = null; + searchFailed = false; + searchController.clear(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(refresh()); + }); + } + } + void onActorChanged() { if (!mounted) return; searchVersion++; setState(() { results = null; searchFailed = false; + searchController.clear(); }); unawaited(refresh()); } @@ -61,6 +89,7 @@ class _SpaceInsightScreenState extends State { final space = actor?.space; final repository = widget.runtime.space; if (space == null || repository == null) return; + setState(() => refreshing = true); try { if (widget.surface == SpaceProjectionKind.relationships) { await repository.refreshRelationships(space); @@ -69,6 +98,8 @@ class _SpaceInsightScreenState extends State { } } on Object { // The source state decides whether cached data remains usable. + } finally { + if (mounted) setState(() => refreshing = false); } } @@ -126,6 +157,7 @@ class _SpaceInsightScreenState extends State { return Scaffold( appBar: AppBar(title: Text(title)), body: StreamBuilder( + key: ValueKey('${space.id}:${widget.surface.name}'), stream: repository.watchSource(source), initialData: OwnerSourceState.unknown, builder: (context, sourceSnapshot) { @@ -143,9 +175,54 @@ class _SpaceInsightScreenState extends State { builder: (context, snapshot) { final payload = snapshot.data?.payload; if (payload == null) { - return const Center(child: Text('Projection non disponible.')); + return refreshing + ? const Center(child: CircularProgressIndicator()) + : MakoloErrorState( + message: 'Projection non disponible.', + onRetry: refresh, + ); } - return relations ? relationsBody(payload) : pilotBody(payload); + final freshness = WorkspaceContextRepository.surfaceFreshness + .evaluate(snapshot.data!, now: DateTime.now()); + if (freshness == FreshnessState.expired) { + return MakoloErrorState( + message: 'Cette observation a expiré.', + onRetry: refresh, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (refreshing) const LinearProgressIndicator(), + MakoloFreshnessNotice( + freshness: const ProjectionSurfaceAdapter().freshnessCue( + freshness, + ), + ), + if (status?.lastErrorCode != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + 'Actualisation indisponible. Observation conservée du ${snapshot.data!.receivedAt.toLocal()}', + ), + ), + TextButton( + onPressed: refresh, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: relations + ? relationsBody(payload) + : SpacePilotSurface(payload: payload), + ), + ], + ); }, ); }, @@ -156,20 +233,25 @@ class _SpaceInsightScreenState extends State { Widget relationsBody(Map payload) { final sections = payload['sections']; if (sections is! Map || sections.isEmpty) { - return const Center( - child: Text('Aucune relation visible dans ce contexte.'), + return const MakoloEmptyState( + title: 'Relations non autorisées ou indisponibles dans ce contexte.', + icon: Icons.lock_outline, ); } final query = searchController.text.trim(); final matches = results?['items']; - final rows = >[]; + final groups = >>{}; if (query.isNotEmpty && matches is List) { + final rows = >[]; for (final item in matches) { if (item is Map) rows.add(Map.from(item)); } + groups['Résultats'] = rows; } else { - for (final section in sections.values) { + for (final entry in sections.entries) { + final section = entry.value; if (section is! Map || section['items'] is! List) continue; + final rows = >[]; for (final item in section['items'] as List) { if (item is Map) { final row = Map.from(item); @@ -179,19 +261,27 @@ class _SpaceInsightScreenState extends State { } } } + groups[spaceRelationshipSectionLabels[entry.key] ?? 'Relations'] = rows; } } return ListView( key: const Key('space-relationships-secondary'), padding: const EdgeInsets.all(20), children: [ - const Text('Relations conservées dans leurs domaines propriétaires.'), + Text( + spaceText(payload['label']) ?? 'Personnes & relations', + style: Theme.of(context).textTheme.headlineSmall, + ), const SizedBox(height: 12), TextField( controller: searchController, + maxLength: 120, onChanged: (_) { searchVersion++; - setState(() => results = null); + setState(() { + results = null; + searchFailed = false; + }); }, onSubmitted: (_) => search(), decoration: InputDecoration( @@ -207,14 +297,37 @@ class _SpaceInsightScreenState extends State { const Text( 'Recherche distante indisponible. Résultats locaux partiels.', ), - if (rows.isEmpty) + if (query.isNotEmpty && results == null) + const Text( + 'Aperçu local partiel. Lancez la recherche pour consulter toutes les relations autorisées.', + ), + if (groups.values.every((rows) => rows.isEmpty)) const Text( 'Aucune relation correspondante dans les données visibles.', ), - for (final row in rows) - ListTile( - title: Text(labelOf(row)), - subtitle: Text(row['relation_type']?.toString() ?? 'Relation'), + for (final entry in groups.entries) ...[ + if (entry.value.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + entry.key, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + for (final row in entry.value) + SpaceRelationshipCard( + row: row, + onOpen: _relationRoute(row) == null + ? null + : () => context.push(_relationRoute(row)!), + ), + ], + if (query.isEmpty && + sections.values.any( + (section) => section is Map && section['has_more'] == true, + )) + const Text( + 'Aperçu partiel. Recherchez pour retrouver les autres relations.', ), if (results?['has_more'] == true) const Text('Autres résultats possibles : affinez la recherche.'), @@ -222,6 +335,12 @@ class _SpaceInsightScreenState extends State { ); } + String? _relationRoute(Map row) => spaceOwnerRoute( + slug: actor?.space.slug, + kind: spaceText(row['kind']), + id: spaceText(row['id']), + ); + String labelOf(Map row) { final profile = row['profile']; if (row['identity'] is String) return row['identity'] as String; @@ -230,53 +349,4 @@ class _SpaceInsightScreenState extends State { } return row['name']?.toString() ?? row['label']?.toString() ?? 'Relation'; } - - Widget pilotBody(Map payload) { - final sections = payload['sections']; - final analytics = sections is Map ? sections['analytics'] : null; - if (analytics is! Map) { - return const Center( - child: Text('Pilotage non autorisé ou indisponible.'), - ); - } - final signals = analytics['signals'] is List - ? analytics['signals'] as List - : const []; - final metrics = analytics['metrics'] is List - ? analytics['metrics'] as List - : const []; - return ListView( - key: const Key('space-pilot-secondary'), - padding: const EdgeInsets.all(20), - children: [ - Text('Piloter', style: Theme.of(context).textTheme.headlineSmall), - const Text('Que savons-nous ? Que faut-il examiner ?'), - const SizedBox(height: 20), - if (signals.isEmpty) - const Text('Aucun signal interprétable à ce stade.'), - for (final signal in signals) - if (signal is Map) - ListTile( - title: Text(signal['title']?.toString() ?? 'Observation'), - subtitle: Text(signal['summary']?.toString() ?? ''), - ), - const SizedBox(height: 12), - ExpansionTile( - title: const Text('Mesures de soutien'), - children: [ - for (final metric in metrics) - if (metric is Map) - ListTile( - title: Text(metric['key']?.toString() ?? 'Mesure'), - subtitle: Text( - metric['state'] == 'known' - ? '${metric['value']}' - : metric['state']?.toString() ?? 'Inconnu', - ), - ), - ], - ), - ], - ); - } } diff --git a/mobile/lib/features/space/space_insight_surfaces.dart b/mobile/lib/features/space/space_insight_surfaces.dart new file mode 100644 index 000000000..7703f24c9 --- /dev/null +++ b/mobile/lib/features/space/space_insight_surfaces.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; + +import '../../design/behavior_states.dart'; +import '../../design/makolo_components.dart'; +import '../../design/makolo_patterns.dart'; +import '../../design/makolo_theme.dart'; +import '../../presentation/contracts/space_presentation.dart'; + +class SpacePilotSurface extends StatelessWidget { + const SpacePilotSurface({super.key, required this.payload}); + final Map payload; + + @override + Widget build(BuildContext context) { + final analytics = spaceMap(spaceMap(payload['sections'])?['analytics']); + if (analytics == null) { + return const MakoloEmptyState( + title: 'Pilotage non autorisé ou indisponible.', + icon: Icons.lock_outline, + ); + } + final signals = analytics['signals'] is List + ? analytics['signals'] as List + : const []; + final metrics = analytics['metrics'] is List + ? analytics['metrics'] as List + : const []; + final coverage = spaceMap(analytics['coverage']); + final generated = spaceText(analytics['generated_at']); + final period = spaceText(analytics['period']); + final money = + spaceMap(payload['capabilities'])?['view_financials'] == true && + analytics['money'] is List + ? analytics['money'] as List + : const []; + return ListView( + key: const PageStorageKey('space-pilot-secondary'), + padding: const EdgeInsets.all(MakoloSpacing.lg), + children: [ + const MakoloDetailHeader( + title: 'Piloter', + subtitle: 'Que faut-il examiner ?', + ), + Text( + 'Source : Analytics', + style: Theme.of(context).textTheme.labelLarge, + ), + if (coverage?['kind'] == 'latest_visible_events' && + coverage?['limit'] is num) + Text( + 'Couverture : les ${coverage!['limit']} derniers événements visibles au maximum.', + ), + if (generated != null) Text('Observation : $generated'), + Text( + period == null + ? 'Période de comparaison non établie.' + : 'Période : $period', + ), + const SizedBox(height: MakoloSpacing.md), + if (analytics['state'] == 'insufficient_data') + const MakoloEmptyState( + title: 'Données insuffisantes', + body: 'Les observations disponibles ne permettent pas encore d’interpréter le fonctionnement.', + ), + if (signals.isEmpty && analytics['state'] != 'insufficient_data') + const Text( + 'Aucun signal interprétable dans les observations disponibles.', + ), + for (final value in signals) + if (spaceMap(value) case final Map signal) + Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.md), + child: MakoloCard( + child: ExpansionTile( + title: Text(spaceText(signal['title']) ?? 'Observation'), + subtitle: spaceText(signal['summary']) == null + ? null + : Text(spaceText(signal['summary'])!), + childrenPadding: const EdgeInsets.all(MakoloSpacing.md), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (spaceText(signal['why']) case final String why) + Text(why), + const SizedBox(height: MakoloSpacing.sm), + if (spaceMap(signal['evidence']) + case final Map evidence) + Text( + '${spaceMetricLabels[evidence['metric_key']] ?? 'Mesure observée'} : ${evidence['value'] ?? 'Inconnu'}', + ), + if (spaceText(signal['owner']) case final String owner) + Text('Source : $owner'), + if (spaceText(signal['uncertainty']) + case final String uncertainty) + Padding( + padding: const EdgeInsets.only(top: MakoloSpacing.sm), + child: MakoloAttentionBlock( + title: 'Limites de cette observation', + body: uncertainty, + icon: Icons.info_outline, + ), + ), + ], + ), + ), + ), + ExpansionTile( + title: const Text('Mesures de soutien'), + children: [ + for (final value in metrics) + if (spaceMap(value) case final Map metric) + ListTile( + title: Text(spaceMetricLabels[metric['key']] ?? 'Mesure'), + subtitle: Text(spaceMetricValue(metric)), + ), + ], + ), + if (money.isNotEmpty) + ExpansionTile( + title: const Text('Montants par devise'), + children: [ + for (final value in money) + if (spaceMap(value) case final Map row) + ListTile( + title: Text( + spaceText(row['currency']) ?? 'Devise non précisée', + ), + subtitle: Text( + 'Brut : ${row['gross'] ?? 'Inconnu'} · Remboursements : ${row['refunds'] ?? 'Inconnu'} · Net : ${row['net'] ?? 'Inconnu'}', + ), + ), + ], + ), + ], + ); + } +} + +class SpaceRelationshipCard extends StatelessWidget { + const SpaceRelationshipCard({super.key, required this.row, this.onOpen}); + final Map row; + final VoidCallback? onOpen; + + @override + Widget build(BuildContext context) { + final profile = spaceMap(row['profile']); + final title = + spaceText(row['identity']) ?? + spaceText(profile?['name']) ?? + spaceText(row['name']) ?? + spaceText(row['label']) ?? + 'Relation'; + final relation = spaceText(row['relation_type']) ?? 'Relation'; + final owner = spaceText(row['owner_identity']); + final state = spaceText(row['status']); + final icon = switch (row['kind']) { + 'team_member' => Icons.badge_outlined, + 'group' || 'audience' => Icons.groups_outlined, + 'partner' => Icons.handshake_outlined, + _ => Icons.person_outline, + }; + return Padding( + padding: const EdgeInsets.only(bottom: MakoloSpacing.sm), + child: MakoloCard( + onTap: onOpen, + semanticLabel: '$title · $relation', + child: Padding( + padding: const EdgeInsets.all(MakoloSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon), + const SizedBox(width: MakoloSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + Text(relation), + if (owner != null && owner != title) Text(owner), + if (state != null) + MakoloStatus(label: spaceStateLabel(state)), + ], + ), + ), + if (onOpen != null) const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} + +const spaceRelationshipSectionLabels = { + 'team': 'Équipe', + 'groups': 'Groupes', + 'crm_contacts': 'Contacts', + 'audiences': 'Audiences', + 'partners': 'Partenaires', +}; diff --git a/mobile/lib/features/space/space_shell_routes.dart b/mobile/lib/features/space/space_shell_routes.dart index 0b68d2148..9407a91b8 100644 --- a/mobile/lib/features/space/space_shell_routes.dart +++ b/mobile/lib/features/space/space_shell_routes.dart @@ -11,6 +11,9 @@ import '../../design/makolo_components.dart'; import '../../design/makolo_theme.dart'; import '../../navigation/secondary_screen.dart'; import '../../sync/owner_source_state.dart'; +import '../../design/surface_states.dart'; +import '../../presentation/projection_surface_adapter.dart'; +import '../../sync/freshness.dart'; import 'space_repository.dart'; import 'space_work_surface.dart'; @@ -147,17 +150,87 @@ class _SpaceShellRootScreenState extends State { return const SizedBox.shrink(); } - return StreamBuilder( - key: ValueKey( - '${widget.surface.name}:${actor.space.id}:' - '${SpaceSyncKeys.perspectiveKey(actor.perspective)}', + final source = switch (widget.surface) { + SpaceShellSurface.now => repository.nowSource( + actor.space, + actor.perspective, ), - stream: _stream(repository, actor), - builder: (context, snapshot) => _SpaceProjectionBody( - surface: widget.surface, - projection: snapshot.data, - fallbackSpaceName: actor.space.slug, + SpaceShellSurface.discover => repository.discoverSource(actor.space), + SpaceShellSurface.work => repository.workSource( + actor.space, + actor.perspective, ), + SpaceShellSurface.us => repository.usSource(actor.space), + }; + return StreamBuilder( + key: ValueKey(source.sourceKey), + stream: repository.watchSource(source), + builder: (context, status) { + if (status.data?.invalidated == true) { + return const MakoloEmptyState( + title: 'Autorité Space révoquée ou indisponible.', + icon: Icons.lock_outline, + ); + } + return StreamBuilder( + key: ValueKey( + '${widget.surface.name}:${actor.space.id}:' + '${SpaceSyncKeys.perspectiveKey(actor.perspective)}', + ), + stream: _stream(repository, actor), + builder: (context, snapshot) { + final projection = snapshot.data; + final freshness = projection == null + ? null + : WorkspaceContextRepository.surfaceFreshness.evaluate( + projection, + now: DateTime.now(), + ); + if (freshness == FreshnessState.expired) { + return MakoloErrorState( + message: 'Cette observation a expiré.', + onRetry: _refresh, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MakoloFreshnessNotice( + freshness: const ProjectionSurfaceAdapter().freshnessCue( + freshness, + ), + ), + if (status.data?.lastErrorCode != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + projection == null + ? 'Actualisation indisponible.' + : 'Actualisation indisponible. Observation conservée du ${projection.receivedAt.toLocal()}', + ), + ), + TextButton( + onPressed: _refresh, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: _SpaceProjectionBody( + surface: widget.surface, + projection: snapshot.data, + fallbackSpaceName: actor.space.slug, + ), + ), + ], + ); + }, + ); + }, ); } } @@ -200,11 +273,15 @@ class _SpaceProjectionBody extends StatelessWidget { }) { final selection = payload['selection']; final state = selection is Map ? selection['state'] : null; - if (state == 'unavailable') { + if (state != 'established' && state != 'available' && state != 'partial') { return _message('Cette vue n’est pas disponible pour le moment.'); } final items = payload['items']; - if (items is! List || items.isEmpty) return _message(emptyMessage); + if (items is! List || items.isEmpty) { + return _message( + state == 'established' || state == 'available' ? emptyMessage : 'La sélection est partielle. Aucun élément connu dans cet aperçu.', + ); + } return _message( '${items.length} élément${items.length > 1 ? 's' : ''} à consulter.', ); diff --git a/mobile/lib/features/space/space_work_collection_screen.dart b/mobile/lib/features/space/space_work_collection_screen.dart new file mode 100644 index 000000000..633af8000 --- /dev/null +++ b/mobile/lib/features/space/space_work_collection_screen.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/runtime/actor_context.dart'; +import '../../app/runtime/app_runtime.dart'; +import '../../design/behavior_states.dart'; +import '../../network/api_error.dart'; +import '../../presentation/contracts/space_presentation.dart'; +import 'space_work_surface.dart'; + +/// An ephemeral continuation of the existing work projection. Every page is +/// authorized again; it never replaces the synced root snapshot. +class SpaceWorkCollectionScreen extends StatefulWidget { + const SpaceWorkCollectionScreen({ + super.key, + required this.runtime, + required this.actor, + required this.section, + }); + final AppRuntime runtime; + final SpaceActorContext actor; + final String section; + @override + State createState() => + _SpaceWorkCollectionScreenState(); +} + +class _SpaceWorkCollectionScreenState extends State { + Map? payload; + bool loading = false; + bool failed = false; + bool denied = false; + int version = 0; + int nextOffset = 0; + bool get actorValid => widget.runtime.actorContext?.value == widget.actor; + + @override + void initState() { + super.initState(); + widget.runtime.actorContext?.addListener(onActorChanged); + unawaited(loadPage()); + } + + @override + void dispose() { + version++; + widget.runtime.actorContext?.removeListener(onActorChanged); + super.dispose(); + } + + @override + void didUpdateWidget(covariant SpaceWorkCollectionScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.runtime.actorContext != widget.runtime.actorContext) { + oldWidget.runtime.actorContext?.removeListener(onActorChanged); + widget.runtime.actorContext?.addListener(onActorChanged); + } + if (oldWidget.runtime != widget.runtime || + oldWidget.actor != widget.actor || + oldWidget.section != widget.section) { + onActorChanged(); + } + } + + void onActorChanged() { + version++; + if (!mounted) return; + setState(() { + payload = null; + nextOffset = 0; + loading = false; + failed = false; + denied = false; + }); + if (actorValid) unawaited(loadPage()); + } + + Future loadPage() async { + if (loading || !actorValid) return; + final currentVersion = ++version; + final offset = nextOffset; + setState(() { + loading = true; + failed = false; + }); + try { + final api = widget.runtime.api; + if (api == null) throw StateError('Owner API unavailable'); + final actor = widget.actor; + final path = Uri( + path: + 'api/v1/organizations/workspaces/${Uri.encodeComponent(actor.space.slug)}/work/', + queryParameters: { + 'section': widget.section, + 'offset': '$offset', + if (!actor.perspective.isAll) 'responsibility': actor.perspective.id!, + }, + ).toString(); + final data = (await api.get(path)).jsonObject(); + if (!mounted || currentVersion != version || !actorValid) return; + final sections = spaceMap(data['sections']); + final section = spaceMap(sections?[widget.section]); + final page = spaceMap(data['page']); + if (spaceMap(data['space'])?['id'] != actor.space.id || + data['responsibility'] != (actor.perspective.id ?? 'all') || + section == null || + section['items'] is! List || + page?['offset'] != offset || + page?['limit'] is! int) { + throw const FormatException( + 'Work page does not match actor or section', + ); + } + final previous = spaceMap( + spaceMap(payload?['sections'])?[widget.section], + ); + final items = >{}; + for (final row in [ + ...?previous?['items'] as List?, + ...section['items'] as List, + ]) { + final item = spaceMap(row); + final source = spaceMap(item?['source']); + if (item != null && + source?['kind'] is String && + source?['id'] is String) { + items['${source!['kind']}:${source['id']}'] = item; + } + } + setState(() { + payload = { + ...data, + 'sections': { + widget.section: {...section, 'items': items.values.toList()}, + }, + }; + nextOffset = offset + (page!['limit'] as int); + denied = false; + }); + } on Object catch (error) { + if (!mounted || currentVersion != version) return; + setState(() { + failed = true; + denied = + error is MakoloApiError && + const {401, 403, 404}.contains(error.statusCode); + if (denied) payload = null; + }); + } finally { + if (mounted && currentVersion == version) setState(() => loading = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(spaceText(payload?['primary_business_label']) ?? 'Métier'), + ), + body: !actorValid || denied + ? const MakoloEmptyState( + title: 'Cette collection n’est plus accessible dans le contexte actuel.', + icon: Icons.lock_outline, + ) + : Column( + children: [ + if (loading) const LinearProgressIndicator(), + if (failed) + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Expanded( + child: Text( + 'La suite est indisponible. Les éléments déjà consultés sont conservés.', + ), + ), + TextButton( + onPressed: loadPage, + child: const Text('Réessayer'), + ), + ], + ), + ), + Expanded( + child: payload == null + ? loading + ? const Center(child: CircularProgressIndicator()) + : MakoloErrorState( + message: 'Collection indisponible.', + onRetry: loadPage, + ) + : SpaceWorkSurface( + payload: payload!, + onLoadMore: (_) { + if (!loading) unawaited(loadPage()); + }, + ), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/space/space_work_surface.dart b/mobile/lib/features/space/space_work_surface.dart index e46c91b20..38e384d17 100644 --- a/mobile/lib/features/space/space_work_surface.dart +++ b/mobile/lib/features/space/space_work_surface.dart @@ -4,11 +4,13 @@ import 'package:go_router/go_router.dart'; import '../../design/behavior_states.dart'; import '../../design/makolo_components.dart'; import '../../design/makolo_theme.dart'; +import '../../presentation/contracts/space_presentation.dart'; class SpaceWorkSurface extends StatelessWidget { - const SpaceWorkSurface({super.key, required this.payload}); + const SpaceWorkSurface({super.key, required this.payload, this.onLoadMore}); final Map payload; + final void Function(String section)? onLoadMore; @override Widget build(BuildContext context) { @@ -29,8 +31,10 @@ class SpaceWorkSurface extends StatelessWidget { if (section == null || items is! List || items.isEmpty) continue; visible.add( _WorkSection( + key: entry.key, label: _string(section['representation']) ?? entry.key, items: items.whereType().map(_copyMap).toList(growable: false), + hasMore: section['has_more'] == true, ), ); } @@ -67,6 +71,24 @@ class SpaceWorkSurface extends StatelessWidget { ), const SizedBox(height: MakoloSpacing.sm), for (final item in section.items) _item(context, item), + if (section.hasMore) ...[ + const Text('Aperçu partiel : d’autres éléments existent.'), + if (onLoadMore != null || + _string(_map(payload['space'])?['slug']) != null) + TextButton( + onPressed: () { + if (onLoadMore != null) { + onLoadMore!(section.key); + } else { + final slug = _string(_map(payload['space'])?['slug'])!; + context.push( + '/space/${Uri.encodeComponent(slug)}/work/${Uri.encodeComponent(section.key)}', + ); + } + }, + child: const Text('Afficher la suite'), + ), + ], const SizedBox(height: MakoloSpacing.lg), ], ], @@ -118,11 +140,37 @@ class SpaceWorkSurface extends StatelessWidget { } final state = _string(item['state']); + final activity = _map(itemContext?['activity']); + final activityTitle = _string(activity?['title']); + final place = _string(_map(itemContext?['place'])?['name']); + if (place != null) { + metadataItems.add(MakoloMetadataItem(place, icon: Icons.place_outlined)); + } + final pricing = _map(itemContext?['pricing']); + final amount = _string(pricing?['unit_price']); + final currency = _string(pricing?['currency']); + if (amount != null && currency != null) { + metadataItems.add( + MakoloMetadataItem( + 'Prix unitaire : $amount $currency', + icon: Icons.payments_outlined, + ), + ); + } + final route = capabilities is List && capabilities.contains('view') + ? spaceOwnerRoute( + slug: _string(_map(payload['space'])?['slug']), + kind: _string(source?['kind']), + id: _string(source?['id']), + ) + : null; return Padding( padding: const EdgeInsets.only(bottom: MakoloSpacing.sm), child: MakoloCard( - onTap: canOpenDayOf + onTap: route != null + ? () => context.push(route) + : canOpenDayOf ? () => context.push('/space/occurrences/${source!['id']}/day-of') : null, semanticLabel: title, @@ -162,12 +210,22 @@ class SpaceWorkSurface extends StatelessWidget { ], if (state != null) ...[ const SizedBox(height: MakoloSpacing.xs), - MakoloStatus(label: state), + MakoloStatus(label: spaceStateLabel(state)), ], + if (activityTitle != null && activityTitle != title) + Text(activityTitle), + if (canOpenDayOf) + TextButton.icon( + onPressed: () => context.push( + '/space/occurrences/${Uri.encodeComponent(source!['id'] as String)}/day-of', + ), + icon: const Icon(Icons.play_circle_outline), + label: const Text('Jour J'), + ), ], ), ), - if (canOpenDayOf) + if (route != null || canOpenDayOf) Padding( padding: const EdgeInsets.only(left: MakoloSpacing.sm, top: 2), child: Icon( @@ -199,9 +257,16 @@ class SpaceWorkSurface extends StatelessWidget { } class _WorkSection { - const _WorkSection({required this.label, required this.items}); + const _WorkSection({ + required this.key, + required this.label, + required this.items, + required this.hasMore, + }); final String label; + final String key; final List> items; + final bool hasMore; } Map? _map(Object? value) { diff --git a/mobile/lib/presentation/contracts/space_presentation.dart b/mobile/lib/presentation/contracts/space_presentation.dart new file mode 100644 index 000000000..6bce71144 --- /dev/null +++ b/mobile/lib/presentation/contracts/space_presentation.dart @@ -0,0 +1,105 @@ +/// Ephemeral language and navigation assembled from authorized owner data. +/// No business state or permission is inferred here. +String? spaceText(Object? value) => + value is String && value.trim().isNotEmpty ? value.trim() : null; + +Map? spaceMap(Object? value) => + value is Map ? Map.from(value) : null; + +String spaceStateLabel(String state) => + const { + 'draft': 'À préparer', + 'published': 'Publié', + 'scheduled': 'Prévu', + 'active': 'Actif', + 'inactive': 'Inactif', + 'completed': 'Terminé', + 'archived': 'Archivé', + 'cancelled': 'Annulé', + 'expired': 'Expiré', + 'submitted': 'Soumis', + 'pending': 'En attente', + 'pending_approval': 'En attente de validation', + 'pending_payment': 'En attente de paiement', + 'approved': 'Validé', + 'confirmed': 'Confirmé', + 'in_progress': 'En cours', + 'fulfilled': 'Accompli', + 'rejected': 'Refusé', + 'refunded': 'Remboursé', + 'unknown': 'Inconnu', + 'unavailable': 'Indisponible', + 'insufficient_data': 'Données insuffisantes', + }[state] ?? + state; + +const spaceMetricLabels = { + 'events_count': 'Événements couverts', + 'published_count': 'Événements publiés', + 'upcoming_count': 'Événements à venir', + 'active_tickets': 'Accès actifs', + 'used_tickets': 'Accès utilisés', + 'confirmed_orders': 'Commandes confirmées', + 'waitlist_waiting': 'Demandes en liste d’attente', + 'attendance_percent': 'Présence observée', +}; + +String spaceMetricValue(Map metric) { + final state = spaceText(metric['state']) ?? 'unknown'; + if (state != 'known' || metric['value'] == null) { + return spaceStateLabel(state == 'known' ? 'unknown' : state); + } + return '${metric['value']}${metric['unit'] == 'percent' ? ' %' : ''}'; +} + +String? spaceOwnerRoute({ + required String? slug, + required String? kind, + required String? id, +}) { + if (slug == null || + id == null || + !const { + 'activity', + 'occurrence', + 'offer', + 'journey', + 'commerce_order', + 'transport_route', + 'vehicle', + 'team_member', + 'group', + 'crm_contact', + 'audience', + 'partner', + }.contains(kind)) { + return null; + } + return '/space/${Uri.encodeComponent(slug)}/retrieval/' + '${Uri.encodeComponent(kind!)}/${Uri.encodeComponent(id)}'; +} + +/// Keep planned dates, availability and lifecycle instants distinct. +List spaceTimingLines(Object? value) { + final timing = spaceMap(value); + if (timing == null) return const []; + final day = spaceText(timing['start_date']); + final clock = spaceText(timing['start_time']); + final zone = spaceText(timing['timezone']); + return [ + if (day != null) + 'Prévu le $day${clock == null ? '' : ' à $clock'}' + '${zone == null ? '' : ' · $zone'}', + for (final entry in const { + 'end_date': 'Fin prévue', + 'available_from': 'Disponible à partir du', + 'available_until': 'Disponible jusqu’au', + 'expires_at': 'Expire le', + 'started_at': 'Commencé le', + 'fulfilled_at': 'Accompli le', + 'confirmed_at': 'Confirmé le', + }.entries) + if (spaceText(timing[entry.key]) case final String instant) + '${entry.value} $instant', + ]; +} diff --git a/mobile/lib/presentation/mps/mps_native_renderer.dart b/mobile/lib/presentation/mps/mps_native_renderer.dart index cab02d9f5..679db61a2 100644 --- a/mobile/lib/presentation/mps/mps_native_renderer.dart +++ b/mobile/lib/presentation/mps/mps_native_renderer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../design/makolo_mark.dart'; import '../../design/makolo_theme.dart'; +import '../../design/presentation_media.dart'; import 'mps_models.dart'; class MpsNativeRenderer extends StatelessWidget { @@ -9,12 +10,16 @@ class MpsNativeRenderer extends StatelessWidget { super.key, required this.package, this.onOpenCredential, + this.mediaBaseUri, + this.mediaScopeKey, }); static const rendererVersion = 1; final MpsPresentationPackage package; final VoidCallback? onOpenCredential; + final Uri? mediaBaseUri; + final String? mediaScopeKey; @override Widget build(BuildContext context) { @@ -168,6 +173,21 @@ class MpsNativeRenderer extends StatelessWidget { case 'Hero': case 'Image': case 'OrganizerMark': + final raw = _value(props['image'] ?? props['src']); + if (raw.isEmpty) return const SizedBox.shrink(); + final uri = mpsContextImageUri(raw, mediaBaseUri); + final alt = mpsString(props['alt']) ?? 'Image de présentation'; + if (uri != null && mediaScopeKey != null) { + return MakoloMediaFrame( + aspect: MakoloMediaAspect.landscape, + semanticLabel: alt, + child: _MpsContextImage( + key: ValueKey('$mediaScopeKey:$uri'), + uri: uri, + alt: alt, + ), + ); + } return AspectRatio( aspectRatio: 16 / 9, child: DecoratedBox( @@ -213,6 +233,79 @@ class MpsNativeRenderer extends StatelessWidget { } } +/// MPS supplies the authorized binding. Only same-origin HTTP representations +/// are requested here, without bearer headers or a persistent media cache. +Uri? mpsContextImageUri(String raw, Uri? base) { + if (raw.isEmpty || + base == null || + !const {'http', 'https'}.contains(base.scheme)) { + return null; + } + final candidate = Uri.tryParse(raw); + if (candidate == null || + candidate.userInfo.isNotEmpty || + candidate.pathSegments.any((part) => part == '..' || part == '.')) { + return null; + } + final uri = base.resolveUri(candidate); + if (!const {'http', 'https'}.contains(uri.scheme) || + uri.origin != base.origin) { + return null; + } + return uri; +} + +class _MpsContextImage extends StatefulWidget { + const _MpsContextImage({super.key, required this.uri, required this.alt}); + final Uri uri; + final String alt; + @override + State<_MpsContextImage> createState() => _MpsContextImageState(); +} + +class _MpsContextImageState extends State<_MpsContextImage> { + late final NetworkImage image; + int attempt = 0; + Future retry() async { + await image.evict(); + if (mounted) setState(() => attempt++); + } + + @override + void initState() { + super.initState(); + image = NetworkImage(widget.uri.toString()); + } + + @override + void dispose() { + // The owner screen unmounts this representation on actor/source changes. + image.evict(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Image( + key: ValueKey(attempt), + image: image, + fit: BoxFit.cover, + excludeFromSemantics: true, + loadingBuilder: (context, child, progress) => progress == null + ? child + : const MakoloMediaPlaceholder(label: 'Chargement du média…'), + errorBuilder: (context, error, trace) => Stack( + fit: StackFit.expand, + children: [ + MakoloMediaPlaceholder(label: '${widget.alt} · Média indisponible'), + Align( + alignment: Alignment.bottomCenter, + child: TextButton(onPressed: retry, child: const Text('Réessayer')), + ), + ], + ), + ); +} + class _InfoBlock extends StatelessWidget { const _InfoBlock({ required this.icon, diff --git a/mobile/test/mps_native_renderer_test.dart b/mobile/test/mps_native_renderer_test.dart index 0d91e65eb..93070f601 100644 --- a/mobile/test/mps_native_renderer_test.dart +++ b/mobile/test/mps_native_renderer_test.dart @@ -1,9 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:makolo_mobile/design/makolo_theme.dart'; import 'package:makolo_mobile/presentation/mps/mps_models.dart'; import 'package:makolo_mobile/presentation/mps/mps_native_renderer.dart'; void main() { + test('MPS context images resolve same-origin representations without credentials', () { + final base = Uri.parse('https://makolo.invalid/'); + expect( + mpsContextImageUri( + '/media/presentations/assets/cover.png', + base, + )?.toString(), + 'https://makolo.invalid/media/presentations/assets/cover.png', + ); + expect( + mpsContextImageUri('https://tracker.invalid/pixel.png', base), + isNull, + ); + expect( + mpsContextImageUri( + 'https://user:password@makolo.invalid/image.png', + base, + ), + isNull, + ); + expect(mpsContextImageUri('../private.png', base), isNull); + expect(mpsContextImageUri('file:///private.png', base), isNull); + expect(mpsContextImageUri('', base), isNull); + }); MpsArtifact artifact({ Set capabilities = const {}, Map links = const {}, @@ -60,6 +85,68 @@ void main() { expect(find.text('Valide'), findsOneWidget); }); + testWidgets( + 'authorized MPS image is requested and failures remain recoverable', + (tester) async { + final original = artifact(); + final value = MpsPresentationPackage( + artifact: MpsArtifact( + resourceKey: original.resourceKey, + purpose: original.purpose, + template: original.template, + theme: original.theme, + context: { + ...original.context, + 'editorial': {'hero_image': '/media/cover.png'}, + }, + capabilities: const {}, + links: const {}, + rendererContract: 'mps.native.v1', + minimumRendererVersion: 1, + ), + manifest: const { + 'layout': { + 'component': 'Page', + 'children': [ + { + 'component': 'Hero', + 'props': { + 'image': {'binding': 'editorial.hero_image'}, + 'alt': 'Couverture du programme', + }, + }, + ], + }, + }, + themeTokens: mpsEssentialTheme, + usedFallback: false, + ); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: Scaffold( + body: MpsNativeRenderer( + package: value, + mediaBaseUri: Uri.parse('https://makolo.invalid/'), + mediaScopeKey: 'viewer:space-a', + ), + ), + ), + ); + final image = tester.widget(find.byType(Image)); + expect( + (image.image as NetworkImage).url, + 'https://makolo.invalid/media/cover.png', + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Média indisponible'), findsOneWidget); + expect(find.text('Réessayer'), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('QRCode remains a revalidated owner action', (tester) async { var opened = false; final value = MpsPresentationPackage( diff --git a/mobile/test/space_mature_composition_test.dart b/mobile/test/space_mature_composition_test.dart new file mode 100644 index 000000000..231477046 --- /dev/null +++ b/mobile/test/space_mature_composition_test.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:makolo_mobile/app/router.dart'; +import 'package:makolo_mobile/app/runtime/actor_context.dart'; +import 'package:makolo_mobile/app/runtime/actor_context_controller.dart'; +import 'package:makolo_mobile/app/runtime/app_runtime.dart'; +import 'package:makolo_mobile/app/session_recovery.dart'; +import 'package:makolo_mobile/auth/token_store.dart'; +import 'package:makolo_mobile/design/makolo_theme.dart'; +import 'package:makolo_mobile/features/space/space_insight_surfaces.dart'; +import 'package:makolo_mobile/features/space/space_work_surface.dart'; +import 'package:makolo_mobile/features/space/space_work_collection_screen.dart'; +import 'package:makolo_mobile/network/makolo_api_client.dart'; +import 'package:makolo_mobile/presentation/contracts/space_presentation.dart'; + +import 'dio_testing.dart'; +import 'fakes.dart'; + +class _ActorStore implements ActorContextStore { + ActorContext value = const PersonalActorContext(); + @override + Future readActorContext(String profileId) async => value; + @override + Future writeActorContext(String profileId, ActorContext context) async { + value = context; + } + + @override + Future removeActorContext(String profileId) async { + value = const PersonalActorContext(); + } +} + +const _pilot = { + 'capabilities': {'view_financials': false}, + 'sections': { + 'analytics': { + 'state': 'known', + 'generated_at': '2026-10-12T08:00:00Z', + 'coverage': {'kind': 'latest_visible_events', 'limit': 40}, + 'signals': [ + { + 'title': 'Des personnes attendent une place disponible.', + 'summary': '3 demandes dans le portefeuille couvert.', + 'why': 'Vérifier les possibilités de délivrance.', + 'owner': 'Analytics', + 'evidence': {'metric_key': 'waitlist_waiting', 'value': 3}, + 'uncertainty': 'Ce constat ne prouve pas une saturation globale.', + }, + ], + 'metrics': [ + { + 'key': 'active_tickets', + 'state': 'known', + 'value': 0, + 'unit': 'count', + }, + { + 'key': 'attendance_percent', + 'state': 'insufficient_data', + 'value': null, + }, + ], + 'money': [ + {'currency': 'USD', 'gross': '10', 'refunds': '0', 'net': '10'}, + ], + }, + }, +}; + +void main() { + testWidgets( + 'work continuation retries the same page and drops revoked content', + (tester) async { + final actors = await ActorContextController.restore( + profileId: 'viewer', + store: _ActorStore(), + ); + addTearDown(actors.dispose); + final space = SpaceActorIdentity(id: 'space-a', slug: 'atelier'); + await actors.selectSpace( + space, + perspective: ActorPerspective.opaque('mandate:viewer'), + ); + var attempts = 0; + var revoked = false; + final client = MockClient((request) async { + expect(request.url.queryParameters['responsibility'], 'mandate:viewer'); + final offset = int.parse(request.url.queryParameters['offset']!); + if (offset == 20 && ++attempts == 1) { + return const MockResponse('{"detail":"Unavailable"}', 503); + } + if (revoked) return const MockResponse('{"detail":"Not found"}', 404); + return MockResponse( + jsonEncode({ + 'space': {'id': 'space-a', 'slug': 'atelier'}, + 'responsibility': 'mandate:viewer', + 'primary_business_label': 'Créations', + 'page': {'offset': offset, 'limit': 20}, + 'sections': { + 'preparation': { + 'representation': 'À préparer', + 'has_more': true, + 'items': [ + { + 'title': 'Œuvre A', + 'source': {'kind': 'activity', 'id': 'a'}, + 'capabilities': ['view'], + }, + if (offset > 0) + { + 'title': 'Œuvre B', + 'source': {'kind': 'activity', 'id': 'b'}, + 'capabilities': ['view'], + }, + ], + }, + }, + }), + 200, + ); + }); + final tokens = MemoryTokenStore( + session: const AuthSession( + accessToken: 'test', + refreshToken: 'test', + profileId: 'viewer', + ), + ); + final runtime = AppRuntime( + tokens: tokens, + session: null, + recovery: SessionRecoveryController(), + actorContext: actors, + api: MakoloApiClient( + baseUri: Uri.parse('https://makolo.invalid/'), + dio: client.dio, + tokenStore: tokens, + ), + ); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: SpaceWorkCollectionScreen( + runtime: runtime, + actor: actors.value as SpaceActorContext, + section: 'preparation', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsOneWidget); + await tester.tap(find.text('Afficher la suite')); + await tester.pumpAndSettle(); + expect(find.textContaining('La suite est indisponible'), findsOneWidget); + expect(find.text('Œuvre A'), findsOneWidget); + await tester.tap(find.text('Réessayer')); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsOneWidget); + expect(find.text('Œuvre B'), findsOneWidget); + revoked = true; + await tester.tap(find.text('Afficher la suite')); + await tester.pumpAndSettle(); + expect(find.text('Œuvre A'), findsNothing); + expect(find.textContaining('n’est plus accessible'), findsOneWidget); + }, + ); + test( + 'knowledge labels preserve zero and distinguish unknown from insufficient', + () { + expect(spaceMetricValue({'state': 'known', 'value': 0}), '0'); + expect(spaceMetricValue({'state': 'known', 'value': null}), 'Inconnu'); + expect( + spaceMetricValue({'state': 'insufficient_data'}), + 'Données insuffisantes', + ); + expect( + spaceTimingLines({ + 'start_date': '2026-10-12', + 'start_time': '14:00', + 'timezone': 'Africa/Lubumbashi', + }).single, + contains('Africa/Lubumbashi'), + ); + expect( + spaceOwnerRoute(slug: 'a', kind: 'private_secret', id: '1'), + isNull, + ); + }, + ); + + for (final width in [360.0, 800.0, 1280.0]) { + testWidgets('Pilot evidence remains readable at $width with large text', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 1000), + textScaler: const TextScaler.linear(1.6), + ), + child: const Scaffold(body: SpacePilotSurface(payload: _pilot)), + ), + ), + ); + expect(find.textContaining('40 derniers événements'), findsOneWidget); + expect(find.text('Période de comparaison non établie.'), findsOneWidget); + await tester.tap( + find.text('Des personnes attendent une place disponible.'), + ); + await tester.pumpAndSettle(); + expect(find.text('Demandes en liste d’attente : 3'), findsOneWidget); + expect( + find.text('Ce constat ne prouve pas une saturation globale.'), + findsOneWidget, + ); + expect(find.text('Montants par devise'), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets( + 'two owner relations to one Profile remain independently recognizable', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildMakoloTheme(), + home: Scaffold( + body: ListView( + children: [ + SpaceRelationshipCard( + row: const { + 'kind': 'team_member', + 'profile': {'id': 'same', 'name': 'Alex'}, + 'relation_type': 'Collaborateur · Équipe', + 'owner_identity': 'Équipe terrain', + }, + onOpen: () {}, + ), + SpaceRelationshipCard( + row: const { + 'kind': 'crm_contact', + 'profile': {'id': 'same', 'name': 'Alex'}, + 'relation_type': 'Contact CRM', + 'owner_identity': 'Contact terrain', + }, + onOpen: () {}, + ), + ], + ), + ), + ), + ); + expect(find.text('Alex'), findsNWidgets(2)); + expect(find.text('Collaborateur · Équipe'), findsOneWidget); + expect(find.text('Contact CRM'), findsOneWidget); + }, + ); + + for (final label in [ + 'Activités', + 'Commerce', + 'Créations', + 'Initiatives', + 'Prestations', + 'Productions', + 'Programmes', + 'Transport', + ]) { + testWidgets('$label work opens the existing owner destination', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + body: SpaceWorkSurface( + payload: { + 'space': {'slug': 'atelier'}, + 'primary_business_label': label, + 'sections': { + 'preparation': { + 'representation': 'À préparer', + 'has_more': true, + 'items': [ + { + 'title': 'Travail concret', + 'source': {'kind': 'activity', 'id': 'a-1'}, + 'capabilities': ['view'], + }, + ], + }, + }, + }, + ), + ), + ), + GoRoute( + path: '/space/:slug/retrieval/:kind/:id', + builder: (_, state) => + Scaffold(body: Text('Owner ${state.pathParameters['id']}')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router(theme: buildMakoloTheme(), routerConfig: router), + ); + expect(find.textContaining('Aperçu partiel'), findsOneWidget); + await tester.tap(find.text('Travail concret')); + await tester.pumpAndSettle(); + expect(find.text('Owner a-1'), findsOneWidget); + }); + } + + testWidgets( + 'production owner route revalidates responsibility and clears data on actor switch', + (tester) async { + final actors = await ActorContextController.restore( + profileId: 'viewer', + store: _ActorStore(), + ); + addTearDown(actors.dispose); + final space = SpaceActorIdentity(id: 'space-a', slug: 'atelier'); + await actors.selectSpace(space); + final client = MockClient((request) async { + expect( + request.url.path, + '/api/v1/organizations/workspaces/atelier/work/', + ); + expect(request.url.queryParameters['kind'], 'activity'); + expect(request.url.queryParameters['id'], 'a-1'); + return MockResponse( + jsonEncode({ + 'space': {'id': 'space-a'}, + 'primary_business_label': 'Créations', + 'selection': { + 'source': {'kind': 'activity', 'id': 'a-1'}, + 'title': 'Œuvre autorisée', + 'state': 'draft', + 'timing': {}, + 'context': {}, + }, + }), + 200, + ); + }); + final tokens = MemoryTokenStore( + session: const AuthSession( + accessToken: 'test', + refreshToken: 'test', + profileId: 'viewer', + ), + ); + final runtime = AppRuntime( + tokens: tokens, + session: tokens.session, + recovery: SessionRecoveryController() + ..restoreLaunchLocation('/space/atelier/retrieval/activity/a-1'), + actorContext: actors, + api: MakoloApiClient( + baseUri: Uri.parse('https://makolo.invalid/'), + dio: client.dio, + tokenStore: tokens, + ), + ); + final router = createMakoloRouter( + runtime, + onAuthenticationChanged: () {}, + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router(theme: buildMakoloTheme(), routerConfig: router), + ); + await tester.pumpAndSettle(); + expect(find.text('Œuvre autorisée'), findsOneWidget); + expect(find.text('À préparer'), findsOneWidget); + await actors.selectPersonal(); + await tester.pumpAndSettle(); + expect(find.text('Œuvre autorisée'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/organizations/api/space_work_projection.py b/organizations/api/space_work_projection.py index 177db1d03..ec87769eb 100644 --- a/organizations/api/space_work_projection.py +++ b/organizations/api/space_work_projection.py @@ -161,6 +161,10 @@ def _offer_item(offer, caps): }, "state": offer.status, "context": { + "pricing": { + "unit_price": str(offer.unit_price), + "currency": offer.currency, + }, "activity": { "id": str(offer.activity_id), "title": offer.activity.title, diff --git a/organizations/test_zs3_space_work_api.py b/organizations/test_zs3_space_work_api.py index 552cb3398..55e631c04 100644 --- a/organizations/test_zs3_space_work_api.py +++ b/organizations/test_zs3_space_work_api.py @@ -343,6 +343,8 @@ def test_commerce_offer_requires_activity_commerce_authority(self): ] self.assertEqual(len(offers), 1) self.assertEqual(offers[0]["title"], "Offre ZS3") + self.assertEqual(offers[0]["context"]["pricing"]["unit_price"], "0.00") + self.assertEqual(offers[0]["context"]["pricing"]["currency"], "USD") self.assertEqual(response.data["sections"]["preparation"]["representation"], "À traiter") self.assertEqual(response.data["sections"]["active"]["representation"], "Commandes actives") self.assertEqual(response.data["sections"]["offers"]["representation"], "Offres") From 3fa7a433093ded5c0fb2dbac6295fafb7f3781f3 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 12:15:42 +0200 Subject: [PATCH 14/16] test(mobile): converge contracts and refresh Now visual references --- .github/workflows/now-golden-baselines.yml | 66 +++--- ANTIGRAVITY_HANDOFF_MAKOLO.md | 189 ++++++++++++++++++ .../mature-mobile-composition-checkpoints.md | 43 +++- mobile/test/goldens/now/s1-min-100.png | Bin 2118 -> 2122 bytes mobile/test/goldens/now/s1-min-130.png | Bin 2368 -> 2424 bytes mobile/test/goldens/now/s1-min-160.png | Bin 2769 -> 3185 bytes mobile/test/goldens/now/s1-rich-100.png | Bin 2320 -> 2319 bytes mobile/test/goldens/now/s2-min-100.png | Bin 3810 -> 3814 bytes mobile/test/goldens/now/s2-min-130.png | Bin 3931 -> 3965 bytes mobile/test/goldens/now/s2-min-160.png | Bin 4320 -> 4725 bytes mobile/test/goldens/now/s2-rich-100.png | Bin 3477 -> 3480 bytes mobile/test/goldens/now/s3-min-100.png | Bin 2937 -> 2915 bytes mobile/test/goldens/now/s3-min-130.png | Bin 3430 -> 3467 bytes mobile/test/goldens/now/s3-min-160.png | Bin 3847 -> 4285 bytes mobile/test/goldens/now/s3-rich-100.png | Bin 3146 -> 3119 bytes mobile/test/goldens/now/s4-min-100.png | Bin 2052 -> 2058 bytes mobile/test/goldens/now/s4-min-130.png | Bin 2310 -> 2377 bytes mobile/test/goldens/now/s4-min-160.png | Bin 2630 -> 3394 bytes mobile/test/goldens/now/s4-rich-100.png | Bin 2251 -> 2251 bytes mobile/test/goldens/now/s5-min-100.png | Bin 2610 -> 2832 bytes mobile/test/goldens/now/s5-min-130.png | Bin 3023 -> 4049 bytes mobile/test/goldens/now/s5-min-160.png | Bin 3435 -> 5651 bytes mobile/test/goldens/now/s5-rich-100.png | Bin 2813 -> 3032 bytes 23 files changed, 258 insertions(+), 40 deletions(-) create mode 100644 ANTIGRAVITY_HANDOFF_MAKOLO.md diff --git a/.github/workflows/now-golden-baselines.yml b/.github/workflows/now-golden-baselines.yml index 5bd7daa78..43922c017 100644 --- a/.github/workflows/now-golden-baselines.yml +++ b/.github/workflows/now-golden-baselines.yml @@ -1,23 +1,25 @@ name: Now Presentation Goldens on: - push: - branches: - - feat/now-presentation-media-mature + pull_request: paths: - - "mobile/**" + - "mobile/lib/features/now/**" + - "mobile/lib/presentation/contracts/now_presentation.dart" + - "mobile/lib/dev/scenarios/now_scenarios.dart" + - "mobile/test/now_golden_test.dart" + - "mobile/test/goldens/now/**" - ".github/workflows/now-golden-baselines.yml" + workflow_dispatch: permissions: - contents: write + contents: read concurrency: - group: now-goldens-feature-505 - cancel-in-progress: false + group: now-goldens-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - golden-baselines: - if: github.ref == 'refs/heads/feat/now-presentation-media-mature' + golden-regression: runs-on: ubuntu-latest timeout-minutes: 25 defaults: @@ -25,41 +27,27 @@ jobs: working-directory: mobile steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: flutter-version: "3.47.3" channel: stable cache: true - - name: Resolve dependencies run: flutter pub get - - - name: Format feature Dart sources - run: bash tool/dart_format.sh write - - - name: Generate Dart sources - run: dart run build_runner build --delete-conflicting-outputs - - - name: Regenerate G01 S1-S5 baselines from production Now + - name: Canonical format check + run: bash tool/dart_format.sh check + - name: Verify approved G01 S1-S5 references + run: flutter test test/now_golden_test.dart + - name: Render review artifacts without changing the branch + if: always() run: flutter test --update-goldens test/now_golden_test.dart - - - name: Commit source formatting and baseline PNGs on feature branch only - working-directory: . - run: | - set -euo pipefail - test "$GITHUB_REF" = "refs/heads/feat/now-presentation-media-mature" - git add mobile/lib mobile/test - if git diff --cached --quiet; then - echo "No format or baseline changes" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "Format Now and establish G01 S1-S5 reference goldens" - # Preserve newer reviewed feature commits; never force-push them away. - git fetch origin feat/now-presentation-media-mature - git rebase origin/feat/now-presentation-media-mature - git push origin HEAD:feat/now-presentation-media-mature + - name: Publish canonical renderings and differences + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: makolo-now-golden-review + path: | + mobile/test/goldens/now/*.png + mobile/test/failures/*.png + if-no-files-found: error + retention-days: 7 diff --git a/ANTIGRAVITY_HANDOFF_MAKOLO.md b/ANTIGRAVITY_HANDOFF_MAKOLO.md new file mode 100644 index 000000000..5afab6309 --- /dev/null +++ b/ANTIGRAVITY_HANDOFF_MAKOLO.md @@ -0,0 +1,189 @@ +# Makolo Mature Mobile Experience Implementation — Antigravity to Codex Handoff + +**Date :** 12 octobre 2026 +**Document :** `ANTIGRAVITY_HANDOFF_MAKOLO.md` +**Contexte de transfert :** Suspension du chantier Antigravity et transfert vers Codex/agent suivant. + +--- + +## 1. Branche locale et SHA HEAD + +- **Branche locale :** `feature/mature-mobile-composition` +- **Base de départ :** `main@f2351b8b` (`f2351b8b5db1ce362b7ddc6ca2951d9d79ce2538`) +- **HEAD SHA actuel :** `0366d81203ee06937427f055d4d31f905172f849` +- **État de synchronisation distante :** Aucun push vers GitHub n'a été effectué conformément à la règle stricte (*AUCUN PUSH INTERMÉDIAIRE*). + +--- + +## 2. Commits créés sur la branche (ordre chronologique) + +Neuf (9) commits locaux ont été validés avec tests ciblés et vérification du formateur Dart : + +1. **`02dc474f`** — `feat(mobile): elevate Now secondary situations and Ongoing continuity dimensions` + - `mobile/lib/features/now/now_screen.dart` (+55 lignes) : enrichissement de `_NowSecondarySituation` avec `whyNow`, signaux de tour S4 (`turnLabel`/`showTurnCue`) et labels d'action S3 (`responseLabel`/`showActionCue`). + - `mobile/lib/features/ongoing/ongoing_screen.dart` (+139, -15 lignes) : ajout de badges `MakoloStatus` dans `_OngoingRow`, structuration de `_detailSections` avec `MakoloAttentionBlock` pour les bloqueurs, section sablier pour l'attente et `emphasis: true` pour les actions côté utilisateur. + +2. **`2da30f1d`** — `feat(mobile): enrich Discovery contextual metadata and focus depth pane` + - `mobile/lib/features/discovery/discovery_mature_view.dart` (+42 lignes) : métadonnées de proximité (`distanceKm` avec icône `near_me_outlined`), complétion du bloc de métadonnées et statut de disponibilité dans `_DiscoveryFocusPane`. + +3. **`caa23650`** — `feat(mobile): adapt Me collective and resource icons to entity archetype` + - `mobile/lib/features/me/me_screen.dart` (+12, -1 lignes) : association d'icônes d'archétype (`business_outlined` pour Space, `badge_outlined` pour Team, `groups_outlined` pour Group) et typage des ressources (`folder_outlined` pour `personal_asset`, `inventory_2_outlined` pour `resource_collection`). + +4. **`db770602`** — `feat(mobile): advance Now presentation topology ownership and elevate SpaceWorkSurface cards` + - `mobile/lib/presentation/contracts/now_presentation.dart` (+33 lignes) : déplacement de l'énumération `NowTopology` (S1–S5) et de la fonction de décision `topologyFor` dans la couche de contrat Presentation, avec getter `topology` sur `NowSituationPresentation`. + - `mobile/lib/features/now/now_screen.dart` (-33 lignes) : suppression de la duplication interne de décision dans le widget, ré-export propre depuis `now_presentation.dart`. + - `mobile/lib/features/space/space_work_surface.dart` (+117, -25 lignes) : remplacement des `ListTile` génériques par des cartes `MakoloCard` structurées avec icônes d'archétypes métier (`departure`, `transport_route`, `occurrence`, `activity`, `offer`, `journey`), métadonnées horaires/places et badge d'état. + +5. **`6922fc52`** — `feat(mobile): elevate History and Transverse Retrieval items with archetype recognition` + - `mobile/lib/features/continuity/history_screen.dart` (+17 lignes) : icônes d'archétypes (`timeline_outlined`, `event_outlined`, `shopping_bag_outlined`, `badge_outlined`, `payments_outlined`, `description_outlined`, `chat_bubble_outline_rounded`) intégrées dans `_HistoryCard`. + - `mobile/lib/features/continuity/transverse_retrieval_screen.dart` (+17 lignes) : icônes contextuelles d'archétypes intégrées sur les éléments de recherche transversale. + +6. **`a1e85ead`** — `feat(mobile): elevate Access collection cards with structured status tone and holder presentation` + - `mobile/lib/features/access/access_collection_screen.dart` (+58, -14 lignes) : extraction de la chaîne agrégée de statut vers un badge `MakoloStatus` avec tone sémantique (`success`, `warning`, `error`, `neutral`), séparation de la ligne temporelle et identification visuelle explicite du tiers bénéficiaire (`holder`). + +7. **`fd06c3c7`** — `feat(mobile): make Me passport card tappable and item-aware` + - `mobile/lib/features/me/me_screen.dart` (+13, -9 lignes) : le passeport affiche les informations du premier item s'il existe et devient cliquable vers la profondeur propriétaire (`onSelectItem`), tout en préservant le texte humain si aucun item n'est déclaré. + +8. **`d370cc67`** — `feat(mobile): render deduplicated consequence in Now primary situation` + - `mobile/lib/features/now/now_screen.dart` (+21, -1 lignes) : rendu de la conséquence (`consequence`) dans la situation principale Now lorsqu'elle apporte une information distincte du sens et du contexte humain, sans duplication textuelle. + +9. **`0366d812`** — `feat(mobile): add bookmark affordance to Discovery cards and focus depth pane` + - `mobile/lib/features/discovery/discovery_mature_view.dart` (+46, -4 lignes) : affordance visuelle de sauvegarde / favori (bookmark) dans les cartes de possibilités `_DiscoveryUnit` et dans le volet d'approfondissement `_DiscoveryFocusPane` selon les capacités serveur `canSave`/`canUnsave`/`savedState`. + +--- + +## 3. Modifications non commitées + +- **Arbre de travail :** Rigoureusement propre (`git status --porcelain` ne retourne aucune modification). +- Aucun fichier de stash en attente. + +--- + +## 4. Surfaces Django et Flutter réellement touchées + +### Surfaces Flutter modifiées et validées : +1. **Now (`mobile/lib/features/now/`)** : + - Découplage de la décision topologique S1–S5 vers `mobile/lib/presentation/contracts/now_presentation.dart`. + - Rendu de la conséquence dédupliquée dans la situation primaire. + - Cues S3 (action) et S4 (tour/attente) sur les situations secondaires. +2. **Découvrir (`mobile/lib/features/discovery/`)** : + - Métadonnées géographiques et de distance. + - Volet de focus d'approfondissement enrichi. + - Affordances visuelles de bookmark / veille. +3. **En cours (`mobile/lib/features/ongoing/`)** : + - Badges de statut dans les lignes de continuité. + - Blocs d'attention `MakoloAttentionBlock` pour les blocages et mise en exergue des actions utilisateur. +4. **Moi (`mobile/lib/features/me/`)** : + - Icônes typées par archétype pour les collectifs et les ressources personnelles. + - Passeport interactif orienté item avec navigation d'approfondissement. +5. **Space Work Surface (`mobile/lib/features/space/`)** : + - Sortie des `ListTile` bruts vers `MakoloCard` avec typage visuel des archétypes métier (transport, départ, activité, offre, démarche). +6. **Historique & Recherche Transversale (`mobile/lib/features/continuity/`)** : + - Reconnaissance visuelle immédiate des archétypes métier dans les listes de résultats. +7. **Accès (`mobile/lib/features/access/`)** : + - Remplacement de la concaténation de texte par des badges `MakoloStatus` typés et clarification de la relation bénéficiaire. + +### Surfaces Django : +- **Audit et tests d'intégration exécutés** : Les projections existantes (`build_personal_now_projection`, `build_space_work_projection`, `build_personal_me_projection`, `build_personal_ongoing_projection`) fournissaient déjà les vérités requises sans qu'une modification des modèles ORM ou une migration ne soit nécessaire. +- **Aucune migration Django n'a été créée** (respect de l'invariant architectural : *aucune migration SQL/Django/Drift*). + +--- + +## 5. Matrice de couverture des contrats Markdown + +| Contrat Markdown | Surface | État d'implémentation | Fichiers impactés | +|---|---|---|---| +| `Makolo_Now_Implementation_Contract_Server_Client_Presentation_S1_S5.md` | Now (Maintenant) | **Conforme / Implémenté** | `now_presentation.dart`, `now_screen.dart` | +| `Makolo_Now_Implementation_Contract_Server_Client_Presentation.md` | Now (Global) | **Conforme / Implémenté** | `now_screen.dart`, `now_selector.dart` | +| `Makolo_Decouvrir_Implementation_Contract_Server_Client_Presentation.md` | Découvrir | **Conforme / Implémenté** | `discovery_mature_view.dart`, `discovery_selector.dart` | +| `Makolo_En_Cours_Implementation_Contract_Server_Client_Presentation.md` | En cours | **Conforme / Implémenté** | `ongoing_screen.dart` | +| `Makolo_Moi_et_Nous_Cadre_Implementation_Contract_Server_Client_Presentation.md` | Moi & Nous | **Conforme / Implémenté** | `me_screen.dart` | +| `Makolo_Mes_Acces_Implementation_Contract_Server_Client_Presentation.md` | Mes Accès | **Conforme / Implémenté** | `access_collection_screen.dart` | +| `Makolo_Historique_Implementation_Contract_Server_Client_Presentation.md` | Historique | **Conforme / Implémenté** | `history_screen.dart` | +| `Makolo_Recherche_Transverse_Implementation_Contract_Server_Client_Presentation.md` | Recherche | **Conforme / Implémenté** | `transverse_retrieval_screen.dart` | +| `Makolo_Space_Now_et_Decouvrir_Cadre_Implementation_Contract_Server_Client_Presentation.md` | Space Work Surface | **Partiel (Work recomposé)** | `space_work_surface.dart` | +| `Makolo_Space_Now_Decouvrir_Donnees_Implementation_Contract_Server_Client_Presentation.md` | Space Attention (Now/Discover) | **Non traité** (serveur renvoie `unavailable` par contrat de sûreté) | `organizations/api/space_attention_projection.py` | +| `Makolo_Demarche_Journey_Implementation_Contract_Server_Client_Presentation.md` | Démarche / Journey Detail | **Conforme sans modification** (déjà mature via `MakoloDetailHeader`, `MakoloAttentionBlock`) | `journey_detail_screen.dart` | +| `Makolo_Forms_Questionnaires_Implementation_Contract_Server_Client_Presentation.md` | Questionnaires | **Conforme sans modification** | `questionnaire_reference_depth_screen.dart` | +| `Makolo_Jour_J_et_Makolo_Live_Implementation_Contract_Server_Client_Presentation.md` | Jour J / Live | **Conforme sans modification** | `jour_j_live_closeout_screen.dart` | +| `Makolo_Makolo_Mark_Implementation_Contract_Server_Client_Presentation.md` | Mark | **Conforme sans modification** | `mark_screen.dart` | +| `Makolo_Notifications_Implementation_Contract_Server_Client_Presentation.md` | Notifications | **Conforme sans modification** | `notification_router.dart` | +| `Makolo_Space_Metier_*` (8 contrats d'archétypes : Activités, Commerce, Créations, Initiatives, Prestations, Productions, Programmes, Transport) | Space Métier | **Partiel** (recomposé génériquement dans `space_work_surface.dart`, volets spécifiques restants) | `mobile/lib/features/space/` | +| `Makolo_Piloter_Implementation_Contract_Server_Client_Presentation.md` | Piloter / Gouvernance | **Non traité** | `mobile/lib/features/space/` | +| `Makolo_Personnes_et_Relations_Implementation_Contract_Server_Client_Presentation.md` | Relations / Collectifs | **Partiel** (icônes et typage faits dans Moi) | `mobile/lib/features/me/` | + +--- + +## 6. Tests lancés et résultats réels + +| Suite de tests | Commande exécutée | Résultat vérifié | +|---|---|---| +| Tests Now complets | `flutter test test/now_screen_test.dart test/now_cross_surface_contract_test.dart` | **PASS** (23/23 tests passés) | +| Tests Découvrir complets | `flutter test test/discovery_mature_screen_test.dart test/discovery_mature_selector_test.dart test/discovery_shell_contract_test.dart` | **PASS** (22/22 tests passés) | +| Tests Moi complets | `flutter test test/me_mature_test.dart` | **PASS** (19/19 tests passés) | +| Tests Space Work Surface | `flutter test test/space_work_surface_test.dart` | **PASS** (3/3 tests passés) | +| Tests Recherche Transversale & Continuité | `flutter test test/transverse_retrieval_screen_test.dart test/pc_continuity_depth_test.dart` | **PASS** (7/7 tests passés) | +| Tests Accès (collection & action réelle) | `flutter test test/access_collection_screen_test.dart test/ar_access_real_world_action_test.dart` | **PASS** (9/9 tests passés) | +| Tests Formatteur Dart | `dart format --output=none --set-exit-if-changed ` | **PASS** (0 modification requise, exit code 0) | +| Tests Django Core Now & Media | `$env:DJANGO_ENV="test"; python manage.py test core.test_now_composition core.test_now_media_api --keepdb` | **PASS** (11/11 tests passés) | +| Tests Django globaux (tentative avec chemin erroné `core.tests.*`) | `manage.py test core.tests.*` | **FAIL (erreur de chemin)** : les fichiers de test sont directement à la racine de l'application `core/` (`core.test_*`) et non dans un sous-dossier `core.tests`. | +| Validation visuelle sur émulateur Android | `flutter devices` | **NOT RUN (bloquant matériel)** : Aucun émulateur Android n'est démarré dans l'environnement local (seuls Windows desktop et Chrome/Edge sont visibles). | + +--- + +## 7. Erreurs rencontrées et diagnostic + +1. **Erreur de chemin de test Django :** + - *Symptôme :* `AttributeError: module 'core.tests' has no attribute 'test_z4_me_api'`. + - *Diagnostic :* Les modules de tests de `core` ne sont pas organisés dans un package `core.tests.*`, mais directement dans des fichiers plats comme `core.test_now_composition`, `core.test_z2_personal_projections`, etc. + - *Résolution :* Spécifier directement le nom de module flat : `core.test_now_composition`. + +2. **Erreur Django WhiteNoise Manifest :** + - *Symptôme :* `ValueError: The file 'brand/favicon.svg' could not be found` si `DJANGO_ENV` n'est pas positionné. + - *Diagnostic :* L'environnement de production/défaut exige les assets statiques compilés par WhiteNoise. + - *Résolution obligatoire :* Toujours préfixer avec `$env:DJANGO_ENV="test"`. + +3. **Quota Antigravity / API :** + - Des épuisements de quota sur les modèles externes ont conduit à des interruptions de session et au basculement d'orchestration vers le modèle actuel, sans perte de code grâce aux checkpoints Git locaux constants. + +4. **Typage Flutter `MeItemPresentation` :** + - *Symptôme :* Erreur de compilation dans `me_screen.dart` lors de la tentative d'accès à `.summary` sur `MeItemPresentation`. + - *Diagnostic :* Le champ dans `MeItemPresentation` s'intitule `.subtitle`. + - *Résolution :* Remplacé par `firstItem?.subtitle`. Test revalidé avec succès. + +--- + +## 8. Chantiers restant à compléter par Codex + +1. **Space Now / Discover Attention Projection (`organizations/api/space_attention_projection.py`) :** + - Le serveur renvoie actuellement `selection.state="unavailable"`, `reason="no_safe_selection_contract"`, `items=[]`. + - Vérifier si une projection sûre peut être construite sans nouveau modèle ni table, ou si le contrat doit conserver cet état d'indisponibilité explicite. +2. **Archétypes Space Métier (Lots F des contrats) :** + - Les 8 surfaces métier spécifiques (Commerce, Transports/Départs, Programmes, Prestations, Initiatives, Créations, Productions, Activités) peuvent bénéficier de gabarits d'approfondissement dédiés au-delà de la recomposition générale de `SpaceWorkSurface`. +3. **Piloter (`Makolo_Piloter_Implementation_Contract_Server_Client_Presentation.md`) :** + - Surface de gouvernance / pilotage Space à inspecter et aligner si des écarts de composition subsistent. + +--- + +## 9. Vérification des permissions, compatibilité et non-régression + +- **Permissions et IDOR :** Les ajouts visuels (affordances de bookmark, ouverture du passeport, détails de départs/jour J) s'appuient strictement sur les permissions et listes de `capabilities` renvoyées par le serveur (`canSave`, `open_day_of`, etc.). Aucune autorité n'a été inférée côté client. +- **Rétrocompatibilité :** L'export de `NowTopology` et `topologyFor` depuis `now_presentation.dart` via `now_screen.dart` maintient une compatibilité à 100% avec les tests existants. +- **Règles d'invariants :** + - Aucun modèle de base de données créé. + - Aucune migration Django ni Drift créée. + - Aucun contournement de mock dans les tests. + +--- + +## 10. Prochain checkpoint précis pour la reprise Codex + +- **Emplacement :** Branche `feature/mature-mobile-composition` au commit `0366d812`. +- **Première action recommandée pour Codex :** + 1. Inspecter `C:\Users\Public\makolo_preparation\Makolo_Space_Now_Decouvrir_Donnees_Implementation_Contract_Server_Client_Presentation.md`. + 2. Inspecter `organizations/api/space_attention_projection.py` et évaluer la faisabilité d'une projection attentionnelle sans invention de vérité métier. + 3. Lancer la suite de tests Django complète avec : + ```powershell + $env:DJANGO_ENV="test"; .\.venv\Scripts\python.exe manage.py test core.test_z1_projection_contract core.test_z2_personal_projections core.test_z4_me_api core.test_z5_detail_api core.test_z6_access_api core.test_z6_history_api --keepdb + ``` + 4. Poursuivre le traitement des contrats restants sans effectuer de push GitHub avant validation finale. diff --git a/docs/operations/mature-mobile-composition-checkpoints.md b/docs/operations/mature-mobile-composition-checkpoints.md index 510f1dc06..0e7d34cde 100644 --- a/docs/operations/mature-mobile-composition-checkpoints.md +++ b/docs/operations/mature-mobile-composition-checkpoints.md @@ -6,7 +6,7 @@ - Owner : Codex, intégrateur unique. - Base : `0366d81203ee06937427f055d4d31f905172f849`, neuf commits depuis `f2351b8b5db1ce362b7ddc6ca2951d9d79ce2538`. - Branche : `feature/mature-mobile-composition` ; `origin/main` encore à la base au démarrage ; aucune PR ouverte (API GitHub). -- Surfaces : projections Organizations, Presentation/Flutter Space, routes propriétaires, tests ciblés, documentation de convergence. +- Surfaces : projections Organizations, Presentation/Flutter Space, routes propriétaires, tests ciblés, références Now et leur gate CI existant, documentation de convergence. - Interdits : modèles/tables/migrations, nouvelle autorité, données inventées, refonte du thème/sync, modification de main, push intermédiaire, merge automatique. - Acceptation : profondeurs réellement accessibles et revalidées, provenance/limites visibles dans Piloter, relations distinctes, absence/fraîcheur/révocation honnêtes, contrats compatibles. - Évidence : tests Django d'autorisation/sélection et Flutter de navigation/présentation/acteur ; formateur canonique, analyse, Goldens pertinents et gates finaux. @@ -31,6 +31,7 @@ La validation visuelle sur appareil n'est pas attestée par le transfert. - Projections Métier : sélection par identité avant les limites d'aperçu, avec les mêmes selectors et permissions ; pagination par section (20 éléments), offset borné ; refus des identités étrangères/malformées et des sections non visibles. Une lentille Activity n'expose pas les routes/véhicules collectifs. Les lieux et arrêts viennent des relations Geography/Transport existantes, avec prefetch. - Profondeurs Flutter : destination propriétaire Space montée pour Métier, Recherche, Historique et Relations ; relecture serveur avec responsabilité courante. Les liens Activity et Jour J restent distincts. Présentation MPS d'une Activity revalidée avant utilisation ; échec explicite, sans ancien artifact affiché après refus. +- Images MPS : le renderer consomme désormais le binding réel, au lieu d'une icône systématique. Représentations HTTP(S) de même origine uniquement, sans en-tête bearer ni cache disque supplémentaire ; alternative textuelle, chargement, erreur et retry ; image décodée évincée au démontage/changement de portée. Les médias absents ne créent pas de cadre orphelin. Tests de résolution/origine et d'erreur récupérable ajoutés. - Continuation Métier : pages éphémères, sans nouveau store/sync ; retry au même offset, déduplication par identité propriétaire, contenu conservé sur erreur technique et retiré sur refus/changement d'acteur. - Piloter : signaux avant mesures ; preuve, source, date, couverture bornée, raison et incertitude visibles. Zéro, inconnu et données insuffisantes restent distincts ; montants par devise seulement avec capability financière. Aucun calcul de tendance/causalité inventé. - Relations : propriétaires conservés séparément, même si un Profil apparaît plusieurs fois ; aperçu et recherche distante distingués, résultats tardifs écartés, sélection revalidée au détail. @@ -40,3 +41,43 @@ La validation visuelle sur appareil n'est pas attestée par le transfert. Le premier checkpoint de formatage a appelé par erreur le binaire direct Dart 3.11.5 du SDK cassé `C:/src/flutter`, malgré son lanceur redirigé vers 3.13.3. L'appel corrigé passe par le SDK actif `C:/src/flutter_3473` (Flutter 3.47.3 / Dart 3.13.3). Les modifications de formatage étrangères au chantier ont été annulées par le vrai formateur, sans reset et sans modifier les neuf commits repris. Validation intermédiaire : 113 tests Django (Space ZS3/ZS4/ZS6, History/Search Space, contrats personnels Z1/Z2/Z4/Z5/Z6) et 485 tests Flutter hors Goldens réussis. Vérification de migrations : aucune modification détectée. Contrat OpenAPI : 12 opérations requises conservées ; 8 fixtures C0 valides. + +## Matrice de convergence des contrats + +Les contrats de données personnels et leurs consolidations restent rattachés aux mêmes surfaces ci-dessous. Ce registre reprend le transfert et ferme les écarts confirmés ; il ne transforme pas une cible documentaire en fonctionnalité existante. + +| Contrat / famille | Propriétaires et profondeur | Résultat de reprise | +|---|---|---| +| Now, S1–S5, données Now | `now_presentation.dart`, `now_screen.dart`, projections Core | Neuf commits conservés ; conséquence et dominance Presentation présentes ; contrôles d'analyse corrigés ; références S1–S5 actualisées pour le rendu réel. | +| Découvrir, données Découvrir/Me | Discovery selector/repository, `discovery_mature_view.dart` | Métadonnées et focus conservés ; bookmark affiché pour une capacité ou un état enregistré, pas pour `unknown` ; suites ciblées vertes. | +| En cours, données Ongoing/Me | Personal projections, `ongoing_screen.dart` | Dimensions et profondeurs existantes conservées ; tests de continuité et suite Flutter complète. | +| Moi/Nous, leurs cadres et données | Me projection, `me_screen.dart`, Space Us et Nous depths | Passeport interactif conservé ; callbacks typés corrigés ; Team/Group/Mandate restent séparés. | +| Space Now/Découvrir, cadre et données | `space_attention_projection.py`, Space repository/root | `unavailable` conservé pour le blocage A précis ; aucun faux calme, y compris source inconnue/partielle. | +| Métier Activités / Generic | Work + Activity/Occurrence | Sélection, continuation, identité, horaires/lieu, Activity liée et Jour J propriétaire ; aucune activité synthétique. | +| Métier Commerce | Work + Offer / CommerceOrder | Offre et commande restent séparées ; prix unitaire et devise de l'Offer lisibles, sans exposer un état Payment ; détail commande relu par Commerce. | +| Métier Créations / Creative | Work + Activity + MPS | Grammaire Créations conservée ; présentation matérielle autorisée accessible à la profondeur Activity, sans CreationModel ni image fictive. | +| Métier Initiatives / Community | Work + Activity / groupes propriétaires | Grammaire Initiatives conservée ; contexte et relations réellement visibles, sans métrique d'impact/autorité inventée. | +| Métier Prestations | Work + Offer / service Journeys | Offre distincte du dossier ; facet progressive et blocages uniquement sur Journey ; détails revalidés par les selectors Services existants. | +| Métier Productions / Media | Work + Activity + MPS | Production reste l'Activity ; média lié au contexte MPS, sans feed ni fichier transformé en production. | +| Métier Programmes / Education | Work + Activity / Occurrence / registration Journey | Programme, session et parcours gardent leurs identités et relations ; pas de Programme ORM ni écran spécialisé artificiel. | +| Métier Transport | Work + Route / Vehicle / Occurrence + Geography | Structure distincte des départs ; arrêts/lieu et places véhicule explicites ; Activity/Jour J séparés ; lentille Activity sans structure globale. | +| Piloter | Analytics projection, `SpacePilotSurface` | Preuves, portée, fraîcheur, lacunes et incertitudes rendues ; aucune tendance/causalité ni capacité financière déduite. | +| Personnes et relations | Team / Groups / CRM / Audiences / Partners | Sections distinctes, recherche distante bornée et profondeur owner-scoped ; aucun UniversalPerson ni fusion par nom. | +| Historique / Recherche transverse | History/Search projections + owner screens | Reconnaissance conservée ; destinations Space montées, commande personnelle montée ; caches personnels/collectifs distincts. | +| Mes Accès | Access collection/detail/credential | Badges et bénéficiaire conservés ; profondeur réelle existante ; aucun credential ajouté aux collections ; tests Z6 et Flutter. | +| Démarche/Journey | Journey detail et owner repositories | Owners, Readiness, paiements, ressources et questionnaires conservés ; tests Z5 et suite Flutter. | +| Requirements/Readiness | Requirement detail et selectors Readiness | Pas de nouveau statut/persistance ; contrats propriétaires conservés, tests Z5 et Flutter. | +| Préparation / ressources contextuelles | Preparation et Resource details | Aucun document assimilé à une exigence satisfaite ; propriétaires et transferts conservés, suite Flutter complète. | +| Forms / Questionnaires | Questionnaire reference depth / submit coordinator | Profondeurs et récupération existantes conservées, suite Flutter complète ; aucune mutation nouvelle. | +| Jour J / Makolo Live | Occurrence owner projections et routes opérateur | Handoff `open_day_of` serveur conservé ; aucun checkpoint assimilé à JourneyStep ; suite Flutter complète. | +| Makolo Mark | Mark owner/repository/routes | Routage acteur conservé ; aucune ingestion ou autorité nouvelle, suite Flutter complète. | +| Notifications | Push/router et récupération de session | Destinations propriétaires conservées ; suite Flutter complète, aucun payload privé ajouté. | +| Maître surfaces personnelles / expérience transversale / Behavior | Owners, selectors, adapter et design system existants | Pas de thème ou sync parallèle ; absence, erreur, snapshot, révocation, acteur, navigation et grandes polices vérifiés sur les changements. | + +Limites assumées : aucune mutation opérateur nouvelle ni formulaire Flutter inventé pour une capability dont le propriétaire n'a pas de flow mobile existant. Une capability ne devient jamais un bouton décoratif. Aucun émulateur/appareil n'a été utilisé ; la validation sur appareil n'est pas effectuée. + +Contrat API additionnel compatible : `work/?kind=&id=` retourne `selection` (0..1, mêmes selectors que la collection, filtrage avant limite). `work/?section=&offset=<0..10000>` retourne une seule section, `page.offset`, `page.limit=20` et `has_more` ; section non visible = 404, offset invalide = 400. Sans ces paramètres, les sections racine restent inchangées ; les nouvelles clés sont additives. Le prix d'une Offer est une chaîne décimale et une devise séparées, sous la permission Commerce déjà exigée ; aucun état Payment n'est ajouté. + +Goldens : les références Now ont été rendues et examinées sur Windows avec Flutter 3.47.3. S5 inclut désormais la conséquence déjà implémentée dans le checkpoint Antigravity. Les contrôles visuels généraux présentent un écart de rasterisation avec les hashes Ubuntu approuvés ; leur manifeste n'a pas été modifié. Le workflow Now existant devient un gate PR en lecture seule, avec artifacts de comparaison ; il ne commit/push plus automatiquement sur une ancienne branche. La vérification Ubuntu de la PR reste le gate canonique. + +Self-review (`ai-development-guardrails.md`) : propriétaires et permissions réutilisés ; lecture seule pour les nouveaux parcours ; retry explicite ; aucune outbox/pending inventée ; identités/acteurs contrôlés et réponses tardives écartées ; aucun modèle/migration/dependency nouveau ; tests non affaiblis ; absence de secrets dans le diff vérifiée avant publication. La qualité sur appareil et le selector collectif Space attentionnel restent des limites explicites, pas des validations prétendues. diff --git a/mobile/test/goldens/now/s1-min-100.png b/mobile/test/goldens/now/s1-min-100.png index ac01646c43af4b7afaeeada61fbd62fcd9a38ae6..eb491904aa8ea12249d6beca09a9ebca9d761746 100644 GIT binary patch delta 866 zcmV-o1D*WF5XumcK~MBaL_t(|obBB)P8?Sp$MN@8+fhVm;0}StIY6YCo7ldAahoV< zO>SUw6CC9Nso)UMq@pAHQZPj%ILQQyFpF7~) z*~c++#Fy6E>unG2qhE|Z-fVmP82!)Q$Imm4|Ml}9XTNWI{M+~EKlMKT+V=RlpI_08 zzgufRZhP>)UqjfH)pMT!0FM)sF#{u$-~$Jfo&pDvk`q|)V)Qu&9~_<>t^4M<>}*#3 z`}S{Zt&e}IM&6Ir_1*2&O~tJE^_rJ8Z)x{m@p@@(J?r3yo8_|XY&K)&SH{=YeRJAc zE4g9f*K1zZyrtcL#p}fn_5;L|Faso$-~$JfJ_8qP)}Dm-V|6|4K~`f&n^;!Pie9gI zS@V{5{}rzn000000N9;A=ir0GlcROt9G9I<`a_=`Jyf~7y}GHGwYclCw9l`2z2uwq z7RPimUzVNCX3YG`*t2;LpW$h1t>h1Gao1yMpI`BM$>%+jF#{u$-~$JfJ_8n$-~$9_ z&DxXjeypyiJ<@8VZ&JFWO+YJWE$VtK?ei;MF8}}l006LDea^uLhbKqtzBw*CoAifL z>GP(CNO!kaHx;wuuW#{sAG~R=g zAOS3sF98>2)T})T@5kzT+7qru`lhVu^QLQ3IV<}57Oxio00000U@7`@pTPee9G)Dl z`{uZ8;Eo@cLYj)K?m)S>qHKKk1^=o~!SKl4|P&O-ly}tgfd$&1$4?vT2@qFKOQNH(WIfk&zutqV>50 z-kp8CY^}Y1@;^NK#pvVBQ;!>?U-T`GYps3xbpBzh9mw}-joX~(oMZQCji1gxY<0iC zeSiK-@8h?p9=A_xB-84-PXK_&lO6*u7XSbN00000Bv{m*g!b$EHhbAY9AA5VYY_V^DcXJ7X|{@nKX+4rw##$ToqTH z-qP;B;`QPO`vGE;j{+T&j{+T&j{*m6HMJ+<{a9UZ_8_bAOq*C%&Wc{Id0F$8cK;Qx z7XSbN004MCedgfZy~F*TzCI{BoAfVzp6N@KyW6Xqidl=h9!q=wiq}iNsi!zLH}gf= z*{sLpN5(6g_wXJbx7JGj;1+j1miGP?ua|tDgOe}=CX;{y9ca|lo`m;fb-mdmtw#DL zrO&hpXyvR$U5}-`f5qzs000000G6xI9K5@CxWCia2W4lI{-sp(LcehtJ6|>^6 zZ}EB|&D2vIn?K%*va?x_$&ZYM&wIEg$E~%JV8vhG;`Krr&q0$O12~g_0t;qpYEQ!Z zvAW*u30EV1Q`Yo(n`=@zEBg8tuNMFS0001BDf&yFz<=%T9q#Y+^+DOdXZlj=?)K`Y zVpjZm&3oJPitlIYO+Cd?Z4;}DvVrSS{t>i!s-fhDj$f~NZ+l+x{Y*X2L6aT>Emo+h zU$d+Jxy(M=(TMs1)KB%%j=tFcs%%#JdV!ZS73k`p%X|&78oiwr&I0%>|Kb1$CC~t& z1R5ZeKm&xD`jYT|tgbhEn$<|(WYawJ-qO6y-*D9|k&zuvh}LHZe0Os8$LQnTlV5oB z^U=rqrye&(Kkr){*GIqn_2cO`Pd%=^vNdjVo-@b3rZxU{_Wf4(`-hXWA9^1@J@vTP zv_>+mUit(8c$|}g0xA~(000000002svlg`{q5b+k&t7(r$8(c00Uwjl0S>bv1rY<0 epa&TI-~I*cT{9@`mcnfS0000c z=4eHNI&-J%YeE~63ZhQCIZwqc3m;J2P80%BCjz2@)7{#&ANTnK-uHUH-uLN)Za}|E zxe#Emf9e2`Y0+d*Ho*uy3aXp$5nci05M$sscmjH(YLhK zNd2)>3#Q`3ANs>Dwe==%CQ`5{+_M1vn~M_wmuD(3)oe0MT)gNRnhp|xM5 z5RV>NJr&G6zk>9B=3vp6nC-cAisq9%=F{3%9982bS!%Jpp~SG7#(g-UgPIe;zw&(5 z`pBy9w9DjJI}Ymv#9ibzfR6SaT$%TR@B&_Z5__*!0s21)7pC(^sO7 z1MVNTMP5-jqKLLA$qCU4h=+UDg2gEC5%CJ-p!in^w9FNSRI4%--1jdhkgM-cD*!7t zS~*53gu6uqL;`cfQ>2F~B0>M$`Lois-zzYcxDIT`&xK|d#u(KOs6D1Xn|Ms!q>5=! zB`%!2QJ)inLxtLELo?m-Q(sTk0qIPqcR6W zTQ}wpHN*O%{|647+#fSx@LW8`p#2AjA8_Y21+A4fiX;yiuAjpuG$aXFbX~ZHvonIy zB+Iq1H}iMoXm1%O+El}s>O2#n)$N4BIm(I}Ir+3_Dd<#y?RNrjMeZ?=AW0P$mH6Ql(wwn#M!;J(QgNodOoW7m$B! z{D@w}vrRP1$9Y@#RKA6$YyeoE>5295eGIRqGc4Eo(+!8wZ5=BJ~xPRY|Ql>Y^fFFf`D literal 2368 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 LtDnm{r-UW|AH^d$ delta 1039 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 S4Ce@&p5VZcfQGXPdH(_@A(edq diff --git a/mobile/test/goldens/now/s2-min-130.png b/mobile/test/goldens/now/s2-min-130.png index d2a4540d9d90b294916e3429325a16d98fe10006..6b8bc637093a7f76cb3a5e1876a2b660d4d816ab 100644 GIT binary patch delta 1926 zcmZ`)dr*^C77rkV(N#LQ5|Nk4F6gu%E)Rhi0=`&Bz-8eBhG!~Ol&2vBfkgO-6rsgJ zX~l$c8l8sr%DUXHJw+#ws-M{coHvtSF~DWTs)JTLSuLN<>bpfT8^bs=RFd)yj}!n+ zr$tr!j%CuMXPn^b<+mkH671isT=HQsw-E9aJm9m|5%IZcB2j<0(Y>T-URy;`Wy$NN z-eST9O(~E-WwR-NW$VYE8jpOfPo&YVA;y*!g*cG6{W4eKQSgBGmijU=bM69kCyy|D~7`jw}(si}eUQZfat~l|buee(i zccdBG)1xWk^1pc&y6Wky_wE=rkn(i>Zrr#d2Hyv72bg?md8LrN1P?j%e>BKPX17I{ zRQ5Z3UO2;~nq;8x8zNfI)n5V$y)CSQ(O13@tKu;i?VTO^YY};W)UFE{k_k;C#>n=I%8mfiC;_i_v%}<>FJx&;` zC4}Plnv0Vt88nKsLlcWgkgo(@8MBBQOHM#txw6^_2wNL7*)l|-rv zNK9jQ$N9^8dt>~G^+Y8vofpK4Vrn8dxCIg1y)%fetwM}GL&m0pHMprXk^*wb;K?+R zs)`6sHg4I-#}Q;C;6IC`AAQ6vUx;=;zs2!E1+K7&oDkd?{O?n={1(?LWf}wJU7*J3 zzj8_FK}BD0p(XecyaQU{2*Jjk91dVnZtLec329_3*_=ZZrz~i~)W@`d8Qqq^i_yJo zC6$F05tQ_C?b;0=lc?6JH14oi0kxmqZELl-Owjj7s_uoY!a^yLD*hHEt%A)m2%XvM zzmW~pSRb=^{byoYM;z3}RKI@}cuG;vqRb%&tm|pCwwa7_wExA)cgRJN4rg#)LYH0} zAi>mqXsc1q442;+elz%KktnTPbZ`Q;`Gy8EvEPl5GB~0W*=BuDt*3@Y3@J%Cwhfl> zlTa<%035eWOYLv381H_cX!Zy@Fe-05;BJBu2WQNEkGC{-ztb7EOJ_vJz{6cr@?O9T zlchq34f(geiP`~O$r!1-yU5J^uamF`>-*p>FXlJA23y(or1&g2*~r+%scx{J`mWQf z%d%_x6#tbGWvLK1)kT)Ah zHZu3xd7XZ|FL-OK?Zoa9Y()WrXLo2H7!*4+!+VZuHO&*S>EllPX=jH9O{yX9>|1wDzjx7I?Cxn}suyoNl9lzbsTGe> z5X}=t(+CE?n2K!+!$bPT#eWGR(bx=O#bE;gZ;Wa3`b*SfhMH?2()-?guD+T>=SX#B^U_OzYKhk4!?Q2k zd#!`!-)X3B@2LB!QSGq`2o3fou<0~5L`&!{tATl}odI-17rWO}Qq$EIUkg6koF^!s zQw{A4AutVP3?{qggX14#0|qnvZ#k8dJN9o>fyQ<=e|d@J{xfaMe(p@@>9&)Y8~*{l C4Ev@4 delta 1879 zcmaJ>YfzJC77kDdh=O&!lzWjbc8deEawjB#BA``4mwaNm1sx{bi9YJd7+3uE!M^36oeb^=4`NHX*`s}Bz7Cl;JubL8#vUEI`a|tww^vE4ZEd1> z|A!Iv2-XtDGO(zfky;W=#hH>0NcOSIb-p+ zER&all!`RVZ_gSbxdhY=7C&nQgl%BzZBH4A^dp}lnQt_Zbv4dS?vV@yGsy^pWiT0K z@VNSsL)jfag)NQbmJDj^IJ)web>&5JP_CLmXPHMA28n|e+nhun>^0F4vP3WI`US~< zB+b78vEa$PCKV4EvKArLzZg7)C0+o|K^h(yZ~4ujuIdu8GuJp?f}}rfB^+YJI(|vz zM!G8X_3bP1VZUV{FpeFbgXGPbtFqRmac>g7zZJh3W&iuQvi|=QwWAkK=yNn2 zF2N~-vN9>U`=Ya?y=it*i}!1+WGbeO{mR+6x9TN=TNR|$nGrS!a(iO~-S3l0=4r<| zcCSaRD+puwI$oltjaRMr^qxu4kwjigReFpfOEScazD!4+vErlUgR;)#chC;MYw75n zWx<;SYB8eax#SK)%vZ}ZZLQ0olU7zTJ?}OVH#eFMFFwU7aN&GPTd#hi?`4L(Tys?6 zO zt%kmjfa||XK45EIc~t#~Cw1>MzW-`h)grG5A$XJ~=pic7c*Q)BNvLw5sn)5GiVm&N zA=MWvtKvSg;%1)T+5F2REZlE40x=#Y-qEQE+(2zTTEj} zv{ZD6v0)b)3XXe~R#(VGp7z;7)|WnG1wRGk(aziM+yBLXxoaF8U#AP&r%qG3Cs??l zLndS24aQ~r&^_B1OK4whSs!HAhBMiQF}%0UI!O@qj^n0}!x&5!B%mM&qc>wLl;OQJ z!LM>tnAo+Vy-C8h>BvoM7cGkKc`c)Kn(+rV9VH3fo#UvJnxL8Dy4{c~hlknAQw8KR zh3YqvaX4&j@AKVXL1Z++Wq2qL@@9K1xmQARS69t!kCDU8qk$Z$YIbBK2q$qXyN9Qi zt_P=1!T?C+;kS_C*M5DcAs}e3O>FuR%!(@TAO4H>RrlHME zhQ-!#rC&=tJ*&$$=bx?(L_|@eh+2#}9jL@}ykN&x|J^eym#TDzyB`h7uwCix(z&LK zDk%oCV4#L(J&Z*Q0Cd8s0`vXmg^a<0l77SPKh-!b8{8Fx1BFKrHiy@>p#Dxi-$*Ph z+CU%d)w+JSsl;9a!ZpO8;j^h}g;eC5xc|K%47JO3cNolspu#z)QUa(u`tRSiPsb;n KZ2LU>#(w~}z~p-X diff --git a/mobile/test/goldens/now/s2-min-160.png b/mobile/test/goldens/now/s2-min-160.png index fc3e6bb9004f2b95cf5a706b1759fe1dccedd228..8ec9ad9b9cafe56ec7e02357b8d6b50a59af0b65 100644 GIT binary patch literal 4725 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 MpF#d*+Yf&6Uwo5lJpcdz diff --git a/mobile/test/goldens/now/s2-rich-100.png b/mobile/test/goldens/now/s2-rich-100.png index cd3a52eaba0fccadecf9e0c42fceaaaec9596b9b..81f63afd412af725157eaa92fc80b0f988ba6b5f 100644 GIT binary patch literal 3480 zcmeH}4OEg@8ply9wZ^P*(o%}%xMk~BZkd{zkXorTqh^LCn)Iousi~FgA*W-(OQ#5`ktjplNx=sAjQjXzlpwNnjE{R zZyTiS^YH?ew}Rj3jlBGJdG0?`BoPEYdYF!B9+SzKuIcUEYrbDp^?H34>-)5#x(8_= z@O1dz;)k0a=2k5$B`s;(onW~l#`oN1<^%ivd)-z=T#7Hl#@;BrW@*0yarKW^t9k&F zs84cm(|ELWD)nmE0gE>t%BkCEn35@`6tR^R2Rk(SABXb`aYoKCdNp$R=#>}CrZ=}yJcxb(q1YI6S1%pgXmSM3uQJHms zFpHZLsOnhed_1jd38?35(``F9;1-#zYXjX~V7>O*PNQ=lpUB>8XtwOLVgs+a2Iu1E zTE~Ce=+c$kK^m{FP2t8yNlH4&0Ee)~DVZ02gU}#v3bu`Ufh42&9*LuxFKAN_a*&tV z4-XT=6qmZUMkWYD(nHx_*|=#lTKG~_jx1U}#mM?uiun!tcNUz|1CS+GonM}J=7C** zr~2s+@0QV{VwZq3pKnSRxFr(ZFOb%2U@=Vm$-iRm#^#W3h*=NPlOoj3Nip2m5KJ%i zqw#w~4X0UM2jSjlS73E!w8(Op(D)y_Y<{qMXFu{of4o^eBBywCy!7pU+g|_bIwlR( z|9DuPII1vwhkWI`zbAgibWHSr4Xv9V`P%M|wNt+M^$cz9_)qR-Iy#&Xey-aiEw{%F z?yWbU*Dn78a>2xJA|`kPap>~At2EO`s6kSU-f2E9vy5{|WTtQZiD2U*fk%27(E1qD z`%bpu${Hd6McTzaKs2^w2(rC>X}{eYYxBz1-!|WQ5LbUni}M!9Aho(_0+jnz7+M_n4gwuO4xEyGthZ~D5Bt@ z5XO1U13;%ZNgKB!QA%A<_wp9jDruF7&9HrJSR~vfUbF+$-2X_m9Yhi7ezeiKjO1z&T@XOX zO73sFJ7ZaokYXx<1RGbYo&}RKUgnsP(`oKwR+MMmjz*3+D7BtNIB`2!|3>RXBd=I* z-`GYRkZVH}Y54<5S-veVe@s z#E(k0xu9A9Dd^~2!E6qp%rgxBmn@xN(uE99%^LzH?VBQpO!}D&8j5JpYx3K-de5d-`RPo4y zGN2c!Dg@K_1nWL3hMi)$cVuV|e~D6(1wq}@`LZ>W3aUIB!xR5A4C^9ZFCbn4HP zNNm>*&tJ?}?>6e!TH-&KD*w|O$uoHyY!n4LiGLLjUOo)@=%ztM{Ca9qa&dSnZqekb z*^?r2502J-jAYhPwaxbj4T`okC}6Sf?$3oh=eMB^L*w5g z0TIF4)uQLvuzAZ+sVO_|wi)oihCX<8Z-mNB}=@F1K* zrKYnUtGYwkd3ZNt#ujuGT#{~?`{F2mCGAOIW%x$R{FcF7K2hXA6ixZxHtt!u0|_S- zwpdXU?OQ3Y?1l*(btSzDA?*rhW9dMaVneHGg+X^&EY=avqH_`a$)RPd{fe;&nI=`y zn@cA1Mx5B4ao6Fw_aluMo=oC(wmL=sP%2X%QYQNPA}!u0mk%n&*va@wXt?2r<)n&$8En7Z0lUh*4xRlGj2rsk delta 1996 zcmY+FdsNbC8pl!c(t6T$tSql>7dx|O)v@w2qNv$rn>5oxH=VS!#0d#6YYK=zS}QF} zV}CPSUJy;!4HPdSk{Z%ZDmtcQDoz?uVPYU)A^sE;7CY1K+4qn4Js`LaVO{QP1E=P+s8MDvv3>f8Ecw~HK;G1wXa%`_>4M)S(o^n_U znM~kGYxIzKgtL*a=8D$dkkG2&IfHu;X~~nRH3rNXPVou~Q&;O5i6L*7`8uL?NVwf{1~Lz4e;#JgAi_I%?R?M>dk#B^~i2Q_imCeV;SJgvePwxB<)JS_lQt;4aA z1dq3m1YX}49GTkYOc%w1TKDrANsgZSk=oLw5-McaoT1i)s`09Ka^h^LUw1Ylw(rrLpF^6=A|yx zj56wa(xIIWSDE@i5$ot|yVuj5Vx6l(HAzHF+O{Ssl&fnH6+hjjpYTEO3!Ack>EVBH zaStF)UTuc#s*(CZp)OdV%B>oqM6l`-VKDDV6ZMbVKWAXt7Kmq~1-loY^hgA(`5k3Y z7X&F>^ij!!HwA5RV}*JMtVJ&|h6K?C9Sv^g7{sV8)if=IFo9w0y{5G{zCTq0(&!R&2W< z1HbXTs*f?Xy=@2Cb73{T=W!2vN%3{Kha^m`JR7)Y1ZETr4q>Y%VgwlhR*e`Epfa5Yjp=yguu1&{=3{z2^n2_DS!*+uBt_o9hzYu0ETw1#2z$?nagr zrVOGn8GACUHvRs)8nc6JI(+Ghx7O#l*{@!Ew<16%#k$ZJ$Z2xJYn4U7NeN+2uNNC! zIlN4!((aL<((rCpCoU`KP%Ck(nh+*~K{GVM&qVRrYFtZHnLTCo^;7y zD0#hf592CjK>(JiDbP4yZdMyIj7ihjl>&r%?b`u7@L|k70i&;*wRPI@V<`OES83#v z=3tYhivw0p=8BG!p2xsChHmza)c1E4g0UIQy_1Ndl7 zACt-E(hF~_)_sS;;)DtkO8dMR595!sScz&^dzsS;?HU#><8!r$qr>ok%LIjc8Upc5 z{K{`Ji7V59#+V7?4>J*DX{}^GjkYQuxEY>UXgEu}=S8ZIC-pE+q??|=U9Ma^>VQBb zxdi9!y4uW`5{bB^*fjuy%F#BInq~A>$D{PqeaRnRc-+9vkIio_=?iDAylY_S(ow

(VcZN7%l?aRMR<#kP=II` zcW4kCtqxo~D$IQBw{#i@Yp@5SEqA7UnOOfjpX#Q@&2mjM?$5Zj@gBQhKTk%zs0X@| zi&?AA5w@$4TuwIjjxSq05{F`Di@6*QGEY*jHU-0A*ily^lF<5I4f3{nomi3Y8otxr q|99Z1^=?+1jlhiZX2Z58131{HElDC~nwGo{K6d!Tq2`0<{`Mb#b2%&k diff --git a/mobile/test/goldens/now/s3-min-100.png b/mobile/test/goldens/now/s3-min-100.png index c99a2153b96947afc21bd527fb64b05b5a656d15..839ec51106dacd52c5687f905265b0e943c2c544 100644 GIT binary patch delta 1683 zcmV;E25kBH7ULF>L4OTNL_t(|obBCDh@58~!13QR*`OsF|6sFdE>caZW((~K&8^Vn zXlY195Y!&hQY?xTnjRF<)Qg8k@TNjdq1BTlYeEhdN&IuOnl;!2LSl=8H6|6c)li#k z=JAjT6xk--d7s($x0}!Byw7i**>~r=!^}JLMgRZ+000000F${0J4`IDbRF=}o+oR^ z5yv88+qjEME&FS!yT@H$YxzYf_1+Hazxl)O?D^KX>mPh<-#w+&UyZxI>+i48jQNO| z9e43cKZYudRVTZ}L42a~J>DU)yk3zJ;|5o0feF3IaPh*)^DSmM9^JJ(9?(^%vp7aEINEGC z!`ks*#tNHv^>?^EBANuj@#wDQ@qn(J2a{0)FOmcoUv;`B;X)mp8ePM!jqDc{ozO)} zTh4Mkx@mbl00000006QoT^6s~Ff%<>hFhBLCi|O6=H;3g_Gx7&4Wxjoy9SV z^U-Fz8P<;fGR(WF=3V_8+#V4E&8p-)lfeWOlW+kGlU)HGlU)G`T&mMG2^Z?%)aaUU zZDhYlsd>5Pjs6B(%>n=b0002ES(Gk|*KL@Yo+`sF&32RhO)2xtTTSyuZ?ayzaPoYs zd9>B(ERIo}k2c%Quy*{HVV-&BEyd_8EP+nSd6V%3E0e$i9T;m8F4V!P(KXH5$bOMc z^UPaK^G1*1Rux^5-iM{*kUy#W9gCzJ66E0O{P7ytkO z000008QfIYBy_#Lx8_=QkdM>s0F$r;C6jOgACb@oe{P^ma`+$3OCF-d`%mqEBkDw7{(ZZeZQ|3jXFp#fo^3S` zpf5G*?z#ERPxrl#CV}2L`^Rl1>T?k>)nXn%&o0+N{nXs=HvhcOO*DClV^Hc>#2Hvc z)(&dbJEVCNrfAYqI$3JnduMKp52EFFe!cK7pR-sad zQ#<%mB#f)jn^$MWBEG)?fxeIyFkKed`?P zQsP9LtpMaQDD`x&x8HR@t?|n?TLH*rIIKVG^Y$A&gC6#J2LL9e)+;MJ&5pKRxmBUcVHm$!T__3?Fqi2e68~Q7^}r``$#8Kp!}M`{^3>kruN6`c@(y zI{1f8f9ZQ4%|3sqGB>~Z>46<6+j-~N9WjV!O2qos zvjHZhM!XhLKQ}kO`AFY;xu*F?z2{DBIy78UW=cewI|*<-CF00%O_|9Q=a|GZ=r`MS z*O_~wlzU6W)?t*}O4N-J@vceUCcq8+CrbRa)OfK*JRb_R9y|E^&A;w@Q?s!MumX|L d3K%QZ{{X9<6^UTGFjW8m002ovPDHLkV1gsBQt$u( delta 1702 zcmY+Dc~FyQ6Tk!ED^;loSTr1g0rf*DASgmq63|FFL?kF80issURFNBu@M2XOq@e8! z7(zrUBDUQ3m2i9_;fjWu5U!9)q#+<66GEDcG-5l`Z~xeNcK4b6&0c&%so?{6z|srt zem0e|#PubHM#6el1+=14-$T}(9wQ#Vda+Oa42y2uu{FA|APcS4d;cUV{|WIy+Zl9# zv)w5UhP6`>=}ZBz=DLbdR{81`0=hpiM?=G*ha|mlx`kZ-WU_=IYG%!m(3cMPdb1-cVPkoKW8gToQuTeOcD;4q=uCh7t*1dB=gGO!uh$y75n}>be+)1zM|SONDUErm@R~MxH$7-;8%VVTIcyw z{0?Cmb;%0w(hQlLB1W_wwa_A;8{8xW|3i5Du^x;lo?+~&jb&(VrVq0Dzczx(6Z=IY zSYI`uA?y?2YEs9&Z*~d{)IF{;Rb3%PmZar4bZqr01>;RY9`rcaqorlU7M9%EmUKNL z(Xa7v1MkY4W1_$})KPpJ`>P*&P5nKq6HPx1Y@B$_!lc;Vpf{V;lq1{5mtt(cAJ%WN zX{IQ8`P$3a_sRADgYz+C6X8<@YrBEedA!e!E>x3>+3NkvecR+twR13*4Q65KA(@o@% zsCV5?Lk8!^bh`)&-PYF<8p#KKcAvovK57F9*Q-M!HM|T`+&TY<&gG{#NfX1*w`aS_K0|5E-Tn>2k1h zX|Rhkx`#=qP{rf2Wr^rhO(bn*<8acMmB&(k3r^Bb+!q_C-RKxn$5?NHvY^abr4=WX(^g+Kw89%;DYKHq}FUAiLl&EQY!JWQl34>fk8by71BJ; z2PeonYb>tu<=284uEeqJ?n~-Gp_~*EW}I^rS(K)0WWN8_XP(}r8b9erWj@vVWLhi3 zlT&7Z1gr@{{Bzesjk@;~cj%sib)Cuka1H#Uga7)uL}2m{Nv3B3&%Gl1)asV2m$%TP z+Vg~%^*L#wydAtCJaUn@CY_Af50RQY;!d`MK6AF)H%Y%3-pZi$>SX>9CQK06YAWMz z!yrPI6>Ysbq0}e)%~haK^TBW_tC6%_wYG9FI%IJFT_;GcJsa5P_naF&7913vk6j!9zS_Uf&>8dFLN9yW8G^cL2Tojqpozz?hZb4uMZ@>>F!G5NS&KtwpSjRoUX}8` z<>e(vJoa~b_r1E5Kjp)+)mzPrTQ>OJaKm)(Mpz8>jAcn~@MDBb(ZUncto}aCM$+dE dn!7U#7JxMS#DP3#D*R)>>*Q&4&GC!h{RIwFWcUC8 diff --git a/mobile/test/goldens/now/s3-min-130.png b/mobile/test/goldens/now/s3-min-130.png index f484a6bc6a80b667afa8f6dcc89378224d24f4c6..528b2f9414923c4526d95cff926e0b7892e1fb48 100644 GIT binary patch literal 3467 zcmeHJc~sL^77ojR8^J_s4*`M&WEoi}fJhRyMkqu2+F9T8saFj zN}wu1i$o~}WDi3mRW=a_U?7o15)49`B0)fsh90#&<8)?@?flpG&%5`Y-|wC8yZ3wd zzN}q=etK&lYXAU%p8sbk3;>`J0|01kTBW0o012;_)tffa$A9-K^-5j!IYsSj5HWr} z0A`QzbM;Ry{wVL=$ycYxcHSP{W5^nx?;8DP^F|{*1G}}}`@TD~YV)aL)R$H^nodrA z_hKOCZR?}H%A)ExR@ttx-*0XC7i~{hh5=+FSSvU*;HX`ptKJ0Mz}nYl;D^5B;(IJh z$i6Me=$SOxsAS~sxT>YRu3u2RL+rUhLk;8iE>;r;t9x5;cVXPa?-R4bow%(LeMEi( zp0B*s7Wq1p4vS|i!BwMO2|zzKxgpSYj&4`fnuH%J=xQhc!t7;^nX7HePL|FKg0qGV zLGfKL#03<$atX^F`kE<0h&v*g`T&-EwX+vSQ}1GqmzKrmM*#m1ad*#>z+9~T6w$!) zkQ?!(ysxjICEYJ`+`8lX*9FaA00YmElTf~`uaL4C1ac8xL@p>$s2q&%W&XQDJ1zxu-bd$EV&U1t z12Q+xRvaE(&7sGRZjg=ct&%@Ih2&W^M}HkdHa%9^j(I3mAD6`tSSp62dY=utU!}UN zJp*5@aS0rgaYGxj$>x;KX91;um>b7&&~wJh~3ZNFO>}opuS104B<^!c&YN9po*Q z#+&3~LP_v$f9GY_@SEf~lb#MBE%O=q3lYJKG-ILtN0RteK5i8-D$s_5l(Dfu;x!i# z+RQAD{Rx>r!CWcHIN>5WsEaFlO9{Zh#MUQ2|8`G22UN6ZB6-D_#sw*ciCvoxi`ulv zwLvNRvmyV&rHVtp@G~2T*op+!{NStcA=*;x=%iC9n1?Zs;cqEqY<#aOVIxsAa;pAUo*yU8Q%RGZq-%0r$2Znr#OWPRx48)mP*;Fp~l+Cm0y1T_rK4 zCm@B<7;vtHghKpyN9F3N6K9thRKQL{WEk#*Lsfn-!1C^X%La z{JrSqI8+lJ3_)p-9O*A5FKPf&9P*v7bk($f=H$gbxf0b8|dJ+ztZmfUC zEuouiamdfk>*k0k-l99hP#EI@7k-d>P&tV&PhCzDAYmR$3&y6~iPQIkdgeee&q^%+ z+aQgVSu}~-XdQ;8CrO;8DYTy`KFkAN1=HxV?0qB9$1Q9^*Fh*5me7)*QoT-3P|AP2 z_w>^J>LiPd@NvUmX7v@N%Os!9Be`{mY7L|9O0x3iO>u<;#kTXpGtt@3-Fs_OErHJT z>q#BLejRxm>}-{)K15Y*>w#O3&IJmud>piFZ0b(5JPYe{Ha8Qp&g0Up9FXZ23J+yy z|Ap$F2m^eJQ}&S3!+pXqX@60V7jUipBT}mBO1+0>$O|Pog1RI-k;J66<@^BFeMfu+ z)mr;-<9(#mCLTf{9ZUD!NK+}PulREgJ#$W_MK%lNs~yuGdt@qZ9b@@Oenseip5D8k z(2R-=)Mt}?bMbT8lw{?D_MJnYx~lQD3>xJ-q%^VR$Ilr8`o%R|4RwJN)pkgkZ)`#o zu@s$kU_}qzr31~~Fs$wXq!m}J;fU)HElzO_P3YdVB7L(u4m+Mef$*8^-s_~fm+UMN)40UCRVyiN+9tc3UQ_Faz{irUCZ4K?Em<|Q9F zyv!e*4wd2HVSE!%-OEBfO?n>nM?C( zr}r;J_STPi+Q#yjAL(B>H}M+WH&+TUyh73Cv?rcaKP)Wh-m1oU`xIe+@Wbc;cA3ap zl6$k$T)2FBbhV?miUM}Xg#-;!IM=v-GfVvs{UnRK-tQKFM*hseZ)L!Che|uuJMD&D S(x$(!eB-}85XJO~JpB_ii=_Sl literal 3430 zcmeH}c~sL^7RM7o5NCiQin0V0h2RpVG~dY5h#lU5?MlskfhPWAN6!RGpFsG{?qr*yZ4>@JHPMe z-uLc%+5Uc>ni~x^0ssI_Z!dTt004{v093YbP*bjGp_gUKNfmR@8@WL_u537iQ~E$m zpyxqABgg2i@}n=j;cm#wq*>lqxur+-AI(kDOHnxJGp5>}$Dy$~Nb~fPM_Q1Rf2|Jp z^|g8S?)DCYung;suYPnrE4gR9(o= z@zZ3IHOerNWi1OU4J};EmvkAQ=p-sN1S)0x*74{@AWD7cp@8#x(gF@2qOJ5zD8TWF zPdL2eg@NTWj@HrA*n(u~U6TIX9o%vhH=Rmg`H%;lxb#_h+ohB{lfHyu%8)uB6unOK zn*vvrU1psCZw(vQz+dV9N1m~xC}U?vs-X6EZ*eLsXviYWog^Q7$(ngi1Dl2+*{jcW z)${RK?RxVg5N%^bDY@o_*oiaBan35|C=_5A2!lS8#oU9$|2A;5GD zM;Du)AHXUVOqQL9`u<_!8j5dy_xzi;;*<88HJn9dzRhQ8JRjLf@lCLnF=$e&-%SX+ z2k!^UY)D^{I7(xcDQ51TmH(KX>B(YTFC@RUYg2AU z)W1)4jO`P?X!fGA$~0c<>fF14t#oa+_+#o*TmZZGE%PJcYlrU2R_ za>yax=MN8t=fk8D=J&yN;KN4FP$4X`=OU5kKkW-S+o)tsoCvCZtqfV(*Y$C;;ff%g zE_LgEkCXkGuF#8QXL?d!Xa_{DfF}20P3@R(BX5Q;4JbwU+ym#~3j2We zvqQ2IkOb_4TXa$dAvr}rLa}3OdYw>{YhA6Jt8XuCZC<){Bsz)C;$o3(h&wq^;&2!a ze_$0%Xee>F4f<1`hTY87v@x5E@pYsMLMHCZ1hr)b(@2amR`<&KZ3iga7bL2+r`$I z3xgZoFum!G+;o(Lo&k8hp&E=2)B3dd*yVmh zMIZLE#8otKY1vw59BdZWDv3$Ujb=XI_Oxipcwp6^+v464Kz$MC^ffknp5mbLPw(to zmhLm9jMaCvozb+d?L4)VOM~B9_2Ep!CF+z-FYEe6DG}^USVV%xB0ko$$uhts4@Wj$ z034$nNvP|eNl9imq0|$Sa>6addT2Eq4o7u|ga{7QNw+LgkGmue_mXM&ycGg0ZA-O> zW5VMhc>@cTWz&&ydf^$-iK+hvgaCaN+z+jG?5ru^(l~%yHn7(NW3VYU^o7#?A$CS5 zs(iERa{LEtDghJ$@GY6JyR^D%2LRU4iY-dKBF#k0ubn4GoVuSB?0oe{ajpJIOBh&7 zA*a^r8(N*-%9Wv|+^(_7E@=3ZYv;pxj_wPNa*E>RfF!3#A!0?gw40g=r9wV2l0rWg zT+y_=hz)1^t_)5PUAp8Pj;9^t&f7VvM-2Upl&e#kY8(`CUlrOT78AiC*>@z2Zs+Uc zV-T(XcFA?8e)n#;JY=&8RKWv@Oj!Kfrqc_n0~}RK0X;*_YtQW7DeH;yWZSq7=~IQ2 zKa|Q{e*p8e#V27`n%T&fPw)8}h1uWZ55wjVtI}8YB@xRWg^4Bi*`xwMY6|#D_Ys$yT5-cEGb9jHq)dXGh_Z$SvQ85Ffmhw?`Ifbae#T zGq!_xbZZp{&h-!%HCRLG8OF9I>q5JG-}lX{Cv)XF+J~4`4H$s=q1|(xT&+{{R|F(W z6Xfo*i<*fT036N~h>=$qtP0EY;bz6CyY2K3un1uN8MD!6E&N!yPks>3Wtt%=hG=^* zL5vAty-f|%UgF#=W$`A&1{a^<+4xgi-B&4aUrCwfHvxGL`T-pjWqAG~X;7ALHVdOj zZ+De^eJ*<{**`58!Q5G8JKjBv#-r&Osco;=+96wNVytwI<11tK1z6#5k~|Ff(Kgv$ z#45G-@&ga&EmRjf$slBfu;TxVHu2}epBeaU2I~D3YVCdtEqO=ap&x?05Bb3x4~Ay^ E1i#$De*gdg diff --git a/mobile/test/goldens/now/s3-min-160.png b/mobile/test/goldens/now/s3-min-160.png index a99d59400fb55f45b921f3f55afee7cdb19e0c91..cbd07cc0d80bb9adb810978ba946026664b52ef5 100644 GIT binary patch literal 4285 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 J)qZ=A{u38o)cgPd diff --git a/mobile/test/goldens/now/s3-rich-100.png b/mobile/test/goldens/now/s3-rich-100.png index b612862bbd0d84049ee19c174fe770444c4ec1dd..199828a63f72420e3e734bbdcfd373a303d884a4 100644 GIT binary patch delta 1828 zcmV+<2iy3{7_S(RL4VmvL_t(|obBCDh+S75!13RCnW80H|6mie8>uE$lR_Jz-Bp?{ zEe(kXg4#tI#G*){lZ7It?Z!n?aHm2#g;rORj454MWTJLwHDj;|grqGB)|gb(Rzqzv zdB;Uwph%j|n|t2e-%LKAO*ZfT?s>U0-+8=uXHEnF000000F&1VJWarrm97JJP4BH8 zM;wlbtxG+))G|{`-MQ4`Yc0PjrQY4)_&rZNKK{ zTb6q8O239MDTBTj0Kk=#?*TKDI0+J20065->2mPeb(_~umEopls~MCJ#l?sy^`GV~ z%|aa-nnHIBLU-Qhnx|ZK>S2ES<;7i}UbIn#WtQ~)3T;uaDU6XqvqRAJG>ua8Q zSJ(3U?MnQ?ev?iCEt6dV7;CE2o`ef^aD22UR~y+kiOfE4bWK{$a(&G+@13>$eggmi z004mHRJt6zcHQRnQ)RfR*=n+XC@`T98P1(OcBaKF*Q2|Z*8{rhbQi}c26N3;Gprqd zWUR1xm;Mj$iHIgaa6P(fc|D*j_mk}eFO&WR7+;e<)?1d8=vO=tI_XXOEp}HIKGB-NiAA z^IWsl3~R?98RnU1-Z(~gVF`3n?t_!D2^5o112Y-zNw`o4$47gbwUK?3P4moKP4hwikk-;2JKx(DyfL+sj=OSY3*e9-Krk1*M+~aC3Gi3?KMa1F#`*tsB2lAEl9G9}r zF2}B<=eU30?j?P_Kk)Fr@0C&?9rw7E^c=})(DwoWxH6NW2_%!y0t*-b000000079~ zn%a}letqxEUUra=+w1_7K?5+8djS-Y&<20rMVaLAFMjsr4=fDpw!xt87}oNs67^#d z@%~Ak6X0F^J0f0PjXO==<(`W&L&Mhu^8Cek~&2JBf1u zH1gjX<;NGV8+`wn-`;$t@5Q`b^FVLka`N7Y^3xJ=L+jZ9lTstzh+*76dvwF0zSn

MJ$k=~nXq`ck9r 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 W)ttpuS~9r^6o8(telF{r5}E)3j-x05 delta 844 zcmeAYXc3s;7`e{V#WAE}&fB{i^JH8Zjz64vxm2@3E$_vPNdX_2CQjPJ7Rsb*Viu$& z5aMCMtI1eyGZcWz7 z7g?OV6ZTq_Hu>}I%bqND;Z@=4w+CB3y06`KJo)(cu>RLAkGF^I|Ml|bLG$F}_BZYK z#Ap95ee+{w{eHjVKVe=!GY)+;|7F^;+GzJnR)!7pCL1tnO}1rZpSVyszF}?Pa-Myq zzwh1KqxZfn`s|v0;r}$3Uir4^`V{fpvR|+Mq+Cp1_v5o^{I!~w*W-WvDtq%aHr+1s z!)x)!uG_Eb+&;3s=l|^MPZtMyO}9_@^K+Jd_P#H#^=oUbzx`VGnEm(Ug-jxo0~oo0 z*2w}fM~Z2t|DkR(>)5v^uO1i8eRZRF-3t`MSs8$!;dKbIqkMnav!0o5djHjYllABP z#r8gHR+&8SQN+=oP#5rgetV^6{rZFMOGO&fv@7{0>oV#BEfW`?{7q#~#^%dc=M~g0 z$q&lfx8`Tq^@R01CoYurZO{!{?)Ty4zlZv3x3M?#-+2|d`t8RHg=?=awn~c0{raOl zot?kEPg}aIFZr5f>KnDWkNex>dv@KM|3 z91M0`@q6o;v47v`o2U6cRe)S$TK^y@dO!c&-PPs$@|V__RV3v4d#}FFCm%k4+BLTR z)k|&Ux1Q5Xp8GiZSg-wYd-3%7{C4;HVpiAdp4+z1(zcRMN@0ovbBj<$LjV)6K-Rs= zefCyoKRYh3WH6g%4G~=R-CZVM>X)JG`hItYZB0;7uLX=woF)z$8I5clSAS1mdap(( z`BbpXz7VnOe{FtLES*yR?cEl`$7Y%P z_r1P1xghLTyT17T`8}3ucTAvWguL&!s5!{8K)4(j5~4D(Pt7^YgXUiSe!NDx;H$4? t!~Y%4Y#gd!`!-)>QDmODfkQm+*nY!J`&q-EJq-edny0Iu%Q~loCIDn%m*fBd diff --git a/mobile/test/goldens/now/s4-min-130.png b/mobile/test/goldens/now/s4-min-130.png index 3e17d470d45f52699a0337658a38c6cb2b51ec20..1acf1301a7140b671fb52be601b279a47d27555e 100644 GIT binary patch delta 1095 zcmV-N1i1T#63G&fL16PqL_t(|obBB)i(FM0$Km(PE*3&GHf~`hR<45J7Z74Eq&8JZ z9YHn{Y{VuA8wFFxfTFcvpMZVFYzPqWp$=gghC zza55|J5`er21I2%R@&%*b9?*O=WKv%V+nzAv)PxzVNNfG+*^g1H`nIZGHFKIDtLpig1N(CE^~`x3 zkHv9s0D$`wlK}!bk^%=UYWOz^dvCAJ*$S($wf*OJhktI*eT)`chqXN1GIQ3<#gnyM z{yueP)lZY>0g<5;Uhvsv0~3-03x917-MY2IUUhTYZXUEu)rqRAUUg^XYqfT`>NPg)xLLWx0tYH; zd+64!9rmi5({}TqWvWh8RrRVno3Kt*Rcq~V)oXZstYe+QwbpT-0+FE~TjXh@1J3R3 zU$3fD!yD=CT5tXA=$zi$d#&~NN9R;kUA=r^`1ss)Zg0O&8g*mF@m}_Je$o1sN&Bfy z-IM3#qnUO8dT-a-gyUURH?Cg3@cM)~$7*rh8vx+`lkWl~k^&e=0000000000004O0 zw9x_Q_V%w=)v4i)^meVces*+D@9n+T`un4EX3h%T)3r9?cvsbpzyA(D2YPR>ee&Vn zH{)A^&EShFlh6V_QUCw|000000001Z+_dA~z^a|x+5NazogW_5`o-$)gV8a)*O}Jp z^WptWoHcV#t?J5*)>0u2yKpaDV&G(f1? zKY4ZUCox7o#o>!8!2h0C&;X$X8X%NF1B4Q2fKUPr5Negz;&5#ZfB~xvbcB;TyC3(e z^TUH$zgWF}Fgm99I@4NxKD?h@iL-jIGr#?E@WqThJ@xGFn^W#zYZt5c;p5$^uH3$L z@Ye8ts_L1Y7hhgh`+mlrs;Xu@FI(@Yduw0L*wd8rWb*TJ*17WQt%KPgDP9Y2aR7vx z{R-L_OaLbF3X|{xBa#9H5F39rJD7Dv!`}Pp=|8g!N3-<>_yQL^#9y}iW}|p*1up;q N002ovPDHLkV1j$gIB@^~ delta 1022 zcmX>p)Fw2+F?xfii(^Q|oVR!P_e}|vIR5du&x%g5mH??-m7oe{sXfdqR{jb0Iy~uu z;98#;DUH9unXAhcE=q5>k)qwJ8Yq33^-Xf4Kv$`7@i`v*W9OI8|2(JidF_7VnR92Z zWtzCq+2g@dqcrg!A0B*;+bp_j)jpd&wtr{Xyl;+ff8TS_eqGGwsy{J{@BfQm7x^_h zbnmvJgYP~T^z7SJadvn7UF#nuPiMWk_slh)q2co6L?+qEeJtt|7l|g{HciW``FH54 zx!L0NYi^%=>9SX!#bz8nh+V;u)ul#e`Ed9;xLgV9e(l>v5@oS~*-T9m6%-;Di z?aNfT$Gb~!Ti(5#KJnhS1*>j_#IIkq)=uGf*_|_n&sWy&s>^c!{`}2P2A9c#%#M>C zn8fO>%!?mSmUEx|>hP3&&@ZLs{GW!df-4?h|npVJv> zy~B@j;zIcprrw*ZcDqV{IQQ?f+B;D${`-sX>DOP%l^=iMpLTuas$ZWzeEagT^3~zy z^VLVK79T$>di?j2<7dyl_^tgdICSc^<)Xh=wmuJjRQdDZ+Qa3?UdX3k_q1i-x1;>S zy*F0+w@@NQ#vkHyFd30_M(@MLwTI=azFd9T`{lhX zDt0`onEz$RPRZK(qjjrRg}qr>z32DUz0LE}t~b^`oZT{6kVSEF9+Lnt!nuNx%+@iC z`@**E?bPn+@5`?qzs%jcFWA;@cI>j5rtc?G-{FgmTR z>waAM|7&hnXnsG#8`kweAx;wqjYc*O)e8&@!~VNZeg6M~`Db^h`OR$qzVB(8tjH`< zpEY~`(TCej8Psx&F9^o%toT_MUTd@W!VBxGk2l{;-SzsI#MiT5>crMvjWw71U-;*3 zm+e|Bs1v_fh4TOV_4miKH&&sc>*H!ZubnLAuKjJxlQ}Upd`sp|H$67@)x3Sr!sapW zex}RU5U@Jl@i&{o0i`%c<`$uhh5#m@vnO}6ND5T{PxjpKiha(m-?u*1Z{|tgY{mA2 eNs{41(J$!*)B69n=5JO6g`}sepUXO@geCx}+!vVu diff --git a/mobile/test/goldens/now/s4-min-160.png b/mobile/test/goldens/now/s4-min-160.png index 7c4f1bcb8f0f4483aacb7cea32549d75391cbfd8..e7f30f1aeee3a849a7cc674f333fcb566a0b278b 100644 GIT binary patch literal 3394 zcmeHJYfw{X8a@$PSeJ`ut)OyA8??A%5fL?lB&Yx@*ja6M{G?&UuhPuhTMWYz$JfFpFE z4sNeoBlN4JScFn>n||2pR$}9kc^zfC|3>jTtDHis;<7)Uk@g{+P}h!ZR@rlDQ-bhR zE-C4y7M)wDi!p^i`rG4Pb4XlkB(cRn#|E=2)`BymFMQAEQ(5GNv-6(dgJ9nDMU?FE z_+M;evO}IS&{E7=Q0`w1rq7OaIiv3O>NLd*EmtX7oq5}m8m$#KYJ=kTg+Wb%zpakY z9||+HVJkI<{47q>8Da{h_)<2--EiL8{(hudE%1w8++jFNt3DJ-c0*+!yF))t1k$9= zJ%H~YcJ2#YW%<>OeI}o5{xE4jVHi<0-u{0OPNY=UB6Y3Lns%$NQohk(lFM3%D8f0U z@Ru2WYW^tn)Z+A`2aeJ#eF%3-T+p@00qn&mHI-v3+b@jGRk?D5+DH72i&aG9@oJ=D z+Q{u+dwit7sLMvB6Y-6Yeq6eB;&A8OKX8?sdic@>p|@DNTohpbAyK~{fiAh^SiXMP zKAIzqdl_rY>;u87c2>nmTmNiFxez-kCnhUu&KrYfbl(5v!akPWm4U?ShsK)RXbM&# z#Iotj60uqq3F{(j78Iwo%O)qa3$-$Zb*YQi@8)iXb6vZ^$Rr`5hbxGm+tvSj5b2#h z`>>k(7+yoJK!0e(-;JFSG|}!M4=2@YQ$+0eqn&mYcxd2sYS`^;N`y-b=9=QcwVMAS z2DavOHqSrHp<-I>)NFVMx?(A{LC9JPJmrldxZJg45tH@p|Zer<|1tU4bG9$do zo89m2E@ZW6RexX6+FbLgWb%Vn_PcSew9qeWymBv{^p(sjzV?pT@>!n4^g^(z`N_o3 zy;zZcpntZ8#FRsV?r$Vf>iP|!KvU!Z+U2>PoWIa2PMGSB7vOk$35k@Cc0&1uv{4#% z5Cl%)$fxR|^%wst-|6i+>9y^22=8XUnfmb5kRtm|LTZs^f;c9fbH(G^cHIMFoA$8(h#j$LSGKN7^lJ zg9kwn@73OD*k#X|>jiNsyBLd# z+f%Rk5qU-X@!UD#{=5#@Xt564(WfBws5=ewG&zm#GrIVwxNnsGPGv?(zyIAP)T%3yCJDWpk$Tta7aR%ZJ-f@z3Q821P z|ICIfRpTAsW2m>2zZHzh?m>;?eWm<15l6*O-;DTRZaZt8)gAX^ci+IaCUzUXQ$gaj zzp8`M@d=5^DM++_yV@4*uheJLgW|bYO6R=J&mpkXth2fc%g9E^<$lfIpTB z-%EHuSpvODpU!o!zz|ONNLB7Md)n%$QEf_k(LXH5XZR-yqB9B=?7D<LXg1*?Z2hti>;^XjO=Nrr!Hs4}0y>x^(Aa;DXHg?e6u| zSZ76pNQTW|qM*>W>9m|r;ON8;hwz7d;+l1R!W#Z^rK)<2%7l7YKJMq#@%j z@3WP){R{Y2MpWjrOztu3{mxm?;w2*$1ZQX^?w4X0P2V0K=}XkhF0xXg46kCV2|9)N zgl4%kxI>6%PL1Y{hS!7|l6dfs7ws;MN!Jg3ucpwJTZfGstwd_L-z0At)LYd3p8U?h e`!SHVtl-Wb-hGJPIpDUkBf$4CuKK{4fBpxlYrh=; literal 2630 zcmeH|eM}Q~7{?Dm3uJ&J+uY=(y2x+=Uls_XTDsL|0&MJuX8%iN{@@KRrWLpcx!)xi|{?i?#!`cc9TzeU_OYE%*n+ zQa{i>ZM=~`c_%>g!l@!84&3*vTdLI#cckafAtWWEvr9nV)LuQEKLTq)F8>fmFH^VD z=*OsHk|Ey?Q@N#N(43FouSRpMsOw`^V4!n&qM`qNQKf^#9mXimw4>2^4ufdS!C~{J z&#c<+2?ZpBNGqlH9$nYk3kcRxGGxYqK~rH%Wa_3(VrV0I4+1fGZ^(WyIZ?<2>cG1xd;cH-3OVmUYNNrIG-#+c$;p-3V7QpTgvY|w{6%P(MONJm45m2|9YRT* zOkEg?1fJRc{usD*Quhx=R4Y}uahv)|6yOv@v=eX1dCL1STCi z!>_BZb>x_t3-Yl%S5mMx!)3g%EX)>UT*-3OgEa2wC751|S_Nl&H5FCFX4j4UY53Po zE>?<2IBH{!5J}ahl;gzwxPN%ydhyS*=M~sM1#U5HJ{PlDqJrB!No%hsA5Kf)#UDTS E8$R?QR{#J2 diff --git a/mobile/test/goldens/now/s4-rich-100.png b/mobile/test/goldens/now/s4-rich-100.png index 663e13df402c573c96e05a6c0ec06f9252ca4d99..25d37de9441dd1e1c82f6108fa467946ec0bf305 100644 GIT binary patch literal 2251 zcmeAS@N?(olHy`uVBq!ia0y~yV9a1(U{>H@1B&n!{`d=|Sc;uILpXq-h9ji|$Y(4L za(7}_cTVOd0|Q5?r;B4q#hkZy_xq)UG93R{tX2}e;_RgxH{Y)7CqeCS>or&gNGb<8||e^^R>;`CD_p$=;9pyYkw%rwt#i+kao4UVGfW+V1zB zYu}3Re{`;|Q$G7KTl4AWea+MV8n(3qaOzktG~|v`sriF>X}#iPu&jk%3Xha)~7|je)soSS;{A` z`|;T{{#wn;>+!#Sg}wQ>_tdQg2j9!}EWQ|4e3R|p$sgOdR{h*E#po2j!M~cb(@o=l zeVx8`bW&aak~buQ;{W2GyD&An_0%bIceG_ed|i$nVpNyU$2S$yfnW4(Mi~ckD+1b^TKW}{2tkV4MQN)p-8C$N#Y-_AHIzTefqTHh;s?(wTZbuYG_Q%s)w_(RD?EP$Gwe2?eF z+y@?PiGIaK`(N+>Mn7%4llFXW%Yjp$QB}QCv+q`ZwAnPTdj9rnr^4&^TK(Sm`JuR^}Re!i-i+}w5|7Dk+&nUQV(+tUC$Lcx{|2@s1{1Meaq`X!-Isa>% zXK9fAyU)|!9*v&K`0t@4JQqSk2`i<%%31x!taq|ggUr^|9teBBcH7z4b@kb$%dc&_ zo$4EVd`sB#t%qlMX&&!hf4c4-xNL~+d-!qRyxSJH@1B&n!{`d=|Sc;uILpXq-h9ji|$Y(4L za(7}_cTVOd0|Q5?r;B4q#hkZyH|EK>${hbVbMkHx2VwJ)5>Jf^#tA3011vo`du266 z!-5o_-f{3OlI@=u@qx+2b43Nia!!S%-2Z})I87{=@VzYef6e!wSIjqWzW4R|^3QjD zvw=2I3e;aGtMAzRdxM+qf?tkbZ%G-}1@7H8T{iwI|F4e6;a~S2|Gk@knrwY``oGHe zUq5epeEi=ZwONm^U%QpA+*iMHz2cmyH(m7^8YXj^IA}Doaj0HkSjZx!FvWqnMJS^o zfQeVYYXPGZHc4y_kC3cA^FTX1KPT_Y;@6Ycrd_kos^5OCYVWM9Q|6}C|9(B)d|2~Y zmwo)_)U(s;{x4mhe|Ft_Pct{(x@yCDKHFw3minpwc)!`Ni)XLhQn5eqzit%1b91Ks+PLUu z{+L=|VrcJM{i=K4ksWb=9~}RCSHHb4d-@xh>5nZ_TW~U42*H z>YdSC<(X;Xdl?w~)JRVW73;U&ntY+I_ImC|J=_QHw|@G4p>{{*+uvDl zUms+yzMFk@zqH-D``Mw*$G={yi^!Ure9Y?jf648CEdLe$cr^Rq@#x9bU(X*F*S7@dLmRd}hiXL199E~_KkE3A*ZcY2^R=sLzg|BrZqI8Mm;JW+Z1&bOJ5FzjzhC*@ z(<1xp%hiYP&(kgT*^x2N5t4d?_aAx4ti~Yxkq28gTe10%%EPEvoBw?@n$KbLZ;8x- z{Wi_8YzYq|Y>bAh*N^)Zq^JMeQk6URSHo5DtJ%M|^sj$az4!WG#cV07WvldmHJE;V z#lCjyHm2u$zgN_MKR6+I$Gzop#m}Dq&8RE?@?QTY(NT}-f`*5`Zg9SPb7p?n`nAFG z8-6XS%ANo1V`%OA@4M_%e4USne07!Iuq^KHg00uu=9Gv2fAsO+JN@OgHQ8(L-m8sp zmYZL3?|I=~V0!G^*RMEny)-K^Wf7(`Mny(LVCaW{&>!YUz4lHWGV!-Tp7C_`b6Mw< G&;$Sm(@gdN diff --git a/mobile/test/goldens/now/s5-min-100.png b/mobile/test/goldens/now/s5-min-100.png index 30877b1eba0ff2cdc2584674b7db15ffd93b7d42..8bf473f4e0d017c5a8188bdddb16f14d3fa93b20 100644 GIT binary patch literal 2832 zcmeH|X;70_6ox+**%WZl)Cz$()rwB5hzPPr0YN%~R0)fqQB-6PGy|f9fT%+Sfm(14 zVQCn#fe;1RO(H~ywgQ4e8c7I<5<&>egph?S4JuPQO;Ovw-amIaGxt3Ay=S@P=j&x? zU}^vWfT8zZj{^Xp5di>N>-2O~Cq@ZkxoXlzx_SHSsRl~##E+`I2J(QH8&K5=<^q60 zlefoi|KvhG>t1dFeJyQBbh*wwtMnK#z4gnic#TV8Rq17qK+hV^wPt&OP6S>HGN?(bJof8xR}=A@bKyx%{64 z6^dzkm_tZOePsd-=)I1k66#q4(%NpxKtD8umv(Dgny6b&nDpcj(`w|IeNu`ir<{zo zAxu}|x8@k>hEiE}L(`|4bPf_`OlJoa;!#B(#nRaoaxX}z1BmU<>F9m^3@Ft$U01M6 z>w-x{x}T=u%I``v-1K+s+N92??m3p8nC+;s@rxZfNmFex&B6C=gvIzMxzFpfFd^Bh zh>}98htdv3i=-Ff#(Qh5BgO8aMV)n+^RCmB%j=I!Qjz3PSdI2E{t?O)Pa{&+g8m@I zl8A4OK92cQsc>hg^Qt%t+UuWs9O#EC=vK0`@AxaT?M{kcFqUJ?_=DMuq0OcO9<62WNnk3amt8 z;WcsoQ&m*M|Dw!BpDk(=P;ICqSI`mV!pi#Jghp&6vI-=hccb~1T>1R=iekr7aq1uXq2WWnh*{sLV4E2dE|BpH>TNPbd~b=yG5#-E>oNBwH*uQmiSmT zNa{nvmb?!LLSEBtxt6+wePqhqQmoU-kU&(KZBC*yww8RXh1| z9+=|<(!!Ivl-Ag!PpJ>{_b)tjFn=S&bdE71aZS*2B8`oRHRZm-S@{%-2*LN#slz0) z^o=l^h7+8!gHq!C_?&LZ6C!&>^nE;yG&UyI2}m)lsCDX{n;Py)FuZ-MDRYl78!-!? z$mvv0j7usAy=+eor$GlHf-_CgGQam(Mu$_KT!DhFm{so;m+#U!9O2g&a{0*zqB@0X+8Ow`=T51_x+0ppN zd`Hbqw1{IDCBgw(cUZ3(FgUF0f-l;?-|}Fbz8ab8q8Wbx99(j+P_5XR+>{`Hia1>P zW?SOL9hcxTwMY?w9W Qi}_>kJ-!}QZbviz1RV{X`2YX_ delta 1299 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 WZPi+NoKnxc5wJ7((@OtCXZ{1ACp)D8 literal 3023 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 rc&Yig=~rPJJ}B0d$z9EkT8U(eD_VaNUh>*Ay) diff --git a/mobile/test/goldens/now/s5-rich-100.png b/mobile/test/goldens/now/s5-rich-100.png index c96fa8e078eb92cac3276d461b38c2ec1fad3171..8c88a10023718c27fbd4130c21fd72fc9ac273f3 100644 GIT binary patch delta 1724 zcmYjReO%Ia9tU(j&Q;G;$Y~RF;?MAHai8ZWgh_l^+cHjW89!(~8!0l9(QvqSx=d@O#s?8>X;8rE zkw_qC2FDIrN77L-!_PT)qAr%`3VVC0g|93sg$Spkg+x%xw5kzh6WHbGwzFkcQ7r>y zvnEH0dpLKFTr3%ko-X_y10#vvNo< zpS#s_{XeE>H`fJ{m&Vrk{`!~KN7EyTY#jzGwO)U?#+jqA;%R15G3~M-ePPv06mn(u zLE8{YvviIpMXc1T4>BqTe6(Z%XOHK-{|Kowa+hb88aq(`RwTfJZ7mGqhtAm0)zrSw z>8O961D@^E;@aim>m9r@XAthh3&{#dLfmnUs; zTp1zH#-=PIH?}S)bwYdlY&Nd>d(Dx*FaK)w*kR^<_7T@VT-5>+k8MCFEClF+`O%^a zQtz25ruCB4p8S?#UOsvm$zJm|%(TzKeq#I6oyh0*jndjqGxWJie_ zl#LbBI|=JlgkLFxCa`dP0?or+Ak1)r_BR*cC3M%xAb$S4V(~b^xATzV7PVwI5toxq zg_Eq(?!Bt_UB29u3I=}z@JiMXrrn4;n2Cdp;5+ko`=YA-I`752m!%%YY9e9*E-cu| zbbCDyiAT^1=UsKd_*Bl^vuXE**ZEYH3VW4@vNEypls<}XiC+5QCB{)%Ozy3+^&eG= zS!W9GE;DZW5XvYI`)U=vZp!C!+QbH)Xn)cV?kL4{{$ujr0gyp4um}wUD6U%qc4xH5 zd)6-G^nPUP-!Gw3OuQQP%r7u#KdZW!)O@(kUZBrJ3Fz*^xsgRrtA0)|9s;ZFX&nQo zxPEzsNJus2y|O$f5?GivlWyGDOb~g_=3$v6sdUCGV?O$ptmurPhKU(=mS#RER~p_w zNfR8txQ=?#aCZu4r@XT4>l{+p0}i{RFjuuVpoyMQfXu^Z)BN*(c|RlBeD_F3QWm9C z0`y;?J|t<~jQ@&4s$kZL&DM4t1yfnsWuR8uHIi{7TRwoMozlyjz!M^>|CodmvdX!6 zKcIVpn9-r^RJo%>*!ar34$bUK@P4QzIq{ABJL|bSBIw_~*de4hmlx=#?Po7#zp}_| z->Rm-A@F8M8urtg=#ThceY)yZY$7bckx!Spo3}V?5rCb0a24n%zOcG)w#sSH1cn7) ztnROI^Z5NZq-l@+YtZr77BQTLVnPrBzeHo}Nhe$6q>tw({YqMC52uLHkb7Cn>lg}Z z&2$MHj0JpMw>BVuWIm#l_t2V$k2UuLeB)-{26!x<8h>CWsTox$#yBS%2m43&pD^<~|u)F0sj4bg+yMdX9%f z=uCfSRzn#Dnkr1|wK>q24r(648Z|Gb1U9GZf zs>Y4o7swBwK!nRK1ah*AWK#q|z)E-cYi7w--`d7gkX8^G<@|f2lLxskMpC*5xT`Ik z_b4W4wL;%PuuVJE)rt!p>VKV2US|AHsJ|T}OT(u6I6gBitEL6Lr4`?2_o-Og4;#$) z_4sB3f%kZa5CTD`yrJa{;5__KFB7vvGt?8LkFnpOZFHgDBLB~2x4&5zoPHe$gIfg8 evjG~gWDl5+FB42XlOi`$m~=elSZ89^7yk#^PlUPv literal 2813 zcmeH|do+}J7{^}}wlZRCYne=KrOOtl?SkVnvaDtaIhbUbtjm-sms}!DcG;r{Yjw&o zX_8JfQ!{FIC^17q>$r7tiD6u3nPJR@@y;+eJG4pXP}==-_xI2Hd!OGqzxVTf-skx~ zm)%?(bygXy0sueRm0;-O&yp zHA6=LU{w*q{;)^v4PNiVP!F_0{eZ;q$+z9M$>c4XX+2fnogpP1!ll_}s~gnmx1Vrx zcorI-{bO~z@m?fwDpJGedDX#O&2^VE!LpUtVpbQ%Hd`>KvlLcTx+GzL>h!)m3-LX5 z)d&COGWRpI?C+2tgHeBC`iVQpS;y&}K6HzyL;)|zFhb5`biB%+t|{Qs>17IbhZLSl zg307Z53o?4VEZMOsSL`e>eb^1p;xG5k+0)r(vfC)r(QjKBPA#dNMe06=%-`-I%Cw&6>s$^0sNS`O~Z6uwUz7 zV_Kg8v;=KialAfujDmEOq~Pk(dhSFuc(X@m?|&nJ?O;fmdNxGlVtB%az^8~A_XaD` zH46d9>7{O%ME7p)`Wb2d)Hvz(F=PZZzlWW}8!pL`o7Px~nuQgv!T zI+;YK)_k4AGgdemh#D=OM47Y{nJ{MNBq7cj)8+|vjKJD-g+gR6X1;0M-%{ZK^{w5~ zTJVF`(!ccsVDA@1@iECZ`D-*elYjb9#w6}c9-8T$cYe~~X2=(dmBxI`9uuI|Ln~9< zF2g1cXh=#`HT;BA&Sdx*mXJX4guqfn-&0QV8ruiyV&_tgSZt74dXwJ>Gbtw#MrX_a z5&sYI@U6~CiKKL<wPA5G#T%qRU~h*9{m_2nph zd{;FreA6fO!~A6cF?jN50R4C$XRBEWgUDY4YZV~F;1It)ZczwW(?3*6lPP#yd@70l zyK<<#CWNlz3ztsgg6m8@L7q@&j|A$4kq{--bDerHMwc?$$=p=Y1uIv&UN=*ApHKvF zq`R7{WyziAwP5h8C=a!N&?4_}fn0l)tE7V&Hs>^+R*p6c)f8Az%<2a#u>>#p8b2oY zXunmm!vZ2{E6E!*8ktm(q21vBbZe68REiD4xbo!p7!d3kmvTRT%zy+Mos!W`4}Z5a)B zT)*JPg^t$NeYGaE2?p*Ow(4S9i8|35ay`KtYb!>;2d7Z!Sm%B1^;-e-FX!Ly;v(^O z|1^mfS>V;)n;mfXu?fRy%)h}p91^=|8@1i@4Q7X7fGd&1k{i6p+JFgq886(C-7<?5I3)WvDx-EyQ;yr2ub=o2m2@i^#zMxnW%_DE4he^v^fJq{mK3uCYfO+z`K}G sg Date: Mon, 12 Oct 2026 13:32:32 +0200 Subject: [PATCH 15/16] fix(ci): generate source dependencies before Now golden checks --- .github/workflows/now-golden-baselines.yml | 4 ++++ ANTIGRAVITY_HANDOFF_MAKOLO.md | 6 +++--- docs/operations/mature-mobile-composition-checkpoints.md | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/now-golden-baselines.yml b/.github/workflows/now-golden-baselines.yml index 43922c017..700c20690 100644 --- a/.github/workflows/now-golden-baselines.yml +++ b/.github/workflows/now-golden-baselines.yml @@ -34,6 +34,10 @@ jobs: cache: true - name: Resolve dependencies run: flutter pub get + - name: Generate owner DTO and Drift sources + run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated-code policy + run: git diff --exit-code -- lib test - name: Canonical format check run: bash tool/dart_format.sh check - name: Verify approved G01 S1-S5 references diff --git a/ANTIGRAVITY_HANDOFF_MAKOLO.md b/ANTIGRAVITY_HANDOFF_MAKOLO.md index 5afab6309..1769c191e 100644 --- a/ANTIGRAVITY_HANDOFF_MAKOLO.md +++ b/ANTIGRAVITY_HANDOFF_MAKOLO.md @@ -1,8 +1,8 @@ # Makolo Mature Mobile Experience Implementation — Antigravity to Codex Handoff -**Date :** 12 octobre 2026 -**Document :** `ANTIGRAVITY_HANDOFF_MAKOLO.md` -**Contexte de transfert :** Suspension du chantier Antigravity et transfert vers Codex/agent suivant. +**Date :** 12 octobre 2026 +**Document :** `ANTIGRAVITY_HANDOFF_MAKOLO.md` +**Contexte de transfert :** Suspension du chantier Antigravity et transfert vers Codex/agent suivant. --- diff --git a/docs/operations/mature-mobile-composition-checkpoints.md b/docs/operations/mature-mobile-composition-checkpoints.md index 0e7d34cde..bf9427b09 100644 --- a/docs/operations/mature-mobile-composition-checkpoints.md +++ b/docs/operations/mature-mobile-composition-checkpoints.md @@ -81,3 +81,10 @@ Contrat API additionnel compatible : `work/?kind=&id=` r Goldens : les références Now ont été rendues et examinées sur Windows avec Flutter 3.47.3. S5 inclut désormais la conséquence déjà implémentée dans le checkpoint Antigravity. Les contrôles visuels généraux présentent un écart de rasterisation avec les hashes Ubuntu approuvés ; leur manifeste n'a pas été modifié. Le workflow Now existant devient un gate PR en lecture seule, avec artifacts de comparaison ; il ne commit/push plus automatiquement sur une ancienne branche. La vérification Ubuntu de la PR reste le gate canonique. Self-review (`ai-development-guardrails.md`) : propriétaires et permissions réutilisés ; lecture seule pour les nouveaux parcours ; retry explicite ; aucune outbox/pending inventée ; identités/acteurs contrôlés et réponses tardives écartées ; aucun modèle/migration/dependency nouveau ; tests non affaiblis ; absence de secrets dans le diff vérifiée avant publication. La qualité sur appareil et le selector collectif Space attentionnel restent des limites explicites, pas des validations prétendues. + +## Validation finale locale et CI + +- Suite finale Flutter après rendu média : 487 tests hors Goldens, tous verts ; analyse sans avertissement et formateur Dart 3.13.3 au vert. +- Django : 113 tests de convergence puis 20 tests Métier après ajout du prix de l’Offer, tous verts ; aucune migration détectée ; contrats OpenAPI/C0 préservés. +- PR unique : #518, sans merge automatique. Mobile CI, CI générale, API Contract, sécurité, contrôles visuels généraux, Android Build et suites PostgreSQL sont verts sur le premier HEAD publié. +- Le premier gate Now a révélé une préparation CI manquante (sources générées absentes). La génération build_runner et la vérification de sa politique sont ajoutées comme dans Mobile CI ; les assertions Golden restent strictes. From f48e2afa78addefbf4dfc095a31a93c7e567f913 Mon Sep 17 00:00:00 2001 From: SomafGroup Date: Mon, 12 Oct 2026 13:36:52 +0200 Subject: [PATCH 16/16] test(mobile): use canonical Ubuntu Now golden renderings --- .../mature-mobile-composition-checkpoints.md | 2 ++ mobile/test/goldens/now/s1-min-100.png | Bin 2122 -> 2118 bytes mobile/test/goldens/now/s1-min-130.png | Bin 2424 -> 2368 bytes mobile/test/goldens/now/s1-min-160.png | Bin 3185 -> 2769 bytes mobile/test/goldens/now/s1-rich-100.png | Bin 2319 -> 2320 bytes mobile/test/goldens/now/s2-min-100.png | Bin 3814 -> 3810 bytes mobile/test/goldens/now/s2-min-130.png | Bin 3965 -> 3931 bytes mobile/test/goldens/now/s2-min-160.png | Bin 4725 -> 4320 bytes mobile/test/goldens/now/s2-rich-100.png | Bin 3480 -> 3477 bytes mobile/test/goldens/now/s3-min-100.png | Bin 2915 -> 2937 bytes mobile/test/goldens/now/s3-min-130.png | Bin 3467 -> 3430 bytes mobile/test/goldens/now/s3-min-160.png | Bin 4285 -> 3847 bytes mobile/test/goldens/now/s3-rich-100.png | Bin 3119 -> 3146 bytes mobile/test/goldens/now/s4-min-100.png | Bin 2058 -> 2052 bytes mobile/test/goldens/now/s4-min-130.png | Bin 2377 -> 2310 bytes mobile/test/goldens/now/s4-min-160.png | Bin 3394 -> 2630 bytes mobile/test/goldens/now/s4-rich-100.png | Bin 2251 -> 2251 bytes mobile/test/goldens/now/s5-min-100.png | Bin 2832 -> 2827 bytes mobile/test/goldens/now/s5-min-130.png | Bin 4049 -> 3403 bytes mobile/test/goldens/now/s5-min-160.png | Bin 5651 -> 3866 bytes mobile/test/goldens/now/s5-rich-100.png | Bin 3032 -> 3030 bytes 21 files changed, 2 insertions(+) diff --git a/docs/operations/mature-mobile-composition-checkpoints.md b/docs/operations/mature-mobile-composition-checkpoints.md index bf9427b09..434250b7f 100644 --- a/docs/operations/mature-mobile-composition-checkpoints.md +++ b/docs/operations/mature-mobile-composition-checkpoints.md @@ -88,3 +88,5 @@ Self-review (`ai-development-guardrails.md`) : propriétaires et permissions ré - Django : 113 tests de convergence puis 20 tests Métier après ajout du prix de l’Offer, tous verts ; aucune migration détectée ; contrats OpenAPI/C0 préservés. - PR unique : #518, sans merge automatique. Mobile CI, CI générale, API Contract, sécurité, contrôles visuels généraux, Android Build et suites PostgreSQL sont verts sur le premier HEAD publié. - Le premier gate Now a révélé une préparation CI manquante (sources générées absentes). La génération build_runner et la vérification de sa politique sont ajoutées comme dans Mobile CI ; les assertions Golden restent strictes. + +- Correction Golden canonique : les 20 scénarios ont été rendus avec succès sur Ubuntu/Flutter 3.47.3 dans le run `38243330011`, HEAD `5b536c258174bf1c101d9de9bd19ead1d3a9d8b9`. Les 20 PNG de l’artifact `11743962104` remplacent les références Windows après inspection. Seule la rasterisation diffère entre ces environnements ; la conséquence S5 et les assertions restent conservées. Les références versionnées sont désormais celles du runner canonique, pas celles du poste Windows. diff --git a/mobile/test/goldens/now/s1-min-100.png b/mobile/test/goldens/now/s1-min-100.png index eb491904aa8ea12249d6beca09a9ebca9d761746..ac01646c43af4b7afaeeada61fbd62fcd9a38ae6 100644 GIT binary patch delta 852 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 diff --git a/mobile/test/goldens/now/s1-min-160.png b/mobile/test/goldens/now/s1-min-160.png index 2b5923b16276f2ba87adb707ff2f9237848cf08a..f94286a4a2c026f2f23a738c99f150a0ba2ac6fa 100644 GIT binary patch literal 2769 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 LtDnm{r-UW|AH^d$ diff --git a/mobile/test/goldens/now/s2-min-100.png b/mobile/test/goldens/now/s2-min-100.png index 4a2e8247cd20f5a544400f98a036e467f4e361a1..3b8109d87bd2c888bfb5e5f57d1e8a9adae0f17f 100644 GIT binary patch delta 1737 zcmYLJdsNa17RGd-)zWRM@li^RrEOy})};0@G!>6q<0JcN3O>@p253xD98tt{J7d{s zF0)oDV!D$FB92Ia57bj?j*6Dz3k6dPlYo;1_yQL9(cVAqIrqEw+{btCx$hk39Pj&) z?*#|=UHqv+G4p-F!6^IQH@ay)H|ZY2+{1bC>}@-b=mSqz?%XfFZM8cfaQe^z1bge& z5Y(aShq>;1!~Ohz0dwzujj(n9GC!|6q2Mf{dd}4ny{*S${)JMk4GmKRt$eSIFzUgBJQls7tkLMNCvTM>JtejR&Gu2cARZ-M zy8stNNZXhVOv)=WQo!6|8`B;7tn5?#>xW*GO+z843CjjZ(7;`|5e$XN zDuXjpD-3hItO-mcX^y*1T`luT6{3)Uf8K7gVf&dlp^q}3(gpUXkj$;4UPq{1klM*R zCG6^y-f0Oa;U(1_5e(?a6nkI>SV1xXA4aOfhmO?8f5$1lU=-}QMCt2xcl_K@b z^GNp6&W@`OljDZlTm^^*=HXlK?(u9Wso(&6iX@n48m(5!_e89n^Bw$8io{X3k}k&! z33;CBoxk)J8LPNC8bQXi*s)zB*R{r~6h#EEm?Kg0^&?yqTdG*P?>PUc{^lRgetvJ) zf#1kaP7_FPd{_xW%Aryf!36ew_nGL!_Mewo`TB$=IMiZu4187Wv2Mw-hg>cjy zNQ*A#6^5^WLnlN4K1nm{=9kz(^fd*0b<8yeYs0!3gtbajk+2+;1SNuB0Bo69I4HUA zxk&zOGV$Z>u_^6ILSw0~P68u;=%=RW=-4q2=)ORPmg-6CU~sgHE0g(}UT6m{{fd@K zZ^3=S<%Zb|MCy+y+SE>VC(^D}WvOggrKjL^^m9SNFT5>sj+MjW#$(pkpWb4J=MzNlZnF%-z-Yj*^&KJ3cxv@=m**} z3umv+UBfC${^IWv@267!7=J_vGS0V+T(7u-uR?OX&Qr}Ae?T;+;;XV)-_#006W=*c zdFw4(#&6aNzy7b~UgRid(SEHMzQHLXua;*SHm<^m zIRKzv(~&3Do-ESpIe9PKD5``TpUC3?X}i4E6!QqDE_=}4oP$?M#?!)j^*z{lzHBH8 zAgPozrk)4CF^|-7T$*@!lyD0d_{H**2^q<)#nG0X=o3v7Wuuq=*SN3w-@d$-8|E|t zUD)R|8|O69;VN57l`c!TqTgWIl1lgxk-wS4^-mD3dHo%e!m&SE)^AeMNggfmg?lG} zJJIhowRO%2i?tMUGzj;rrWoi6crUM*efiFfIIGn|4g>@G%xp!#7N z0+*XYkEF5a&hm#gf4;&c&SYP~$_r~CEJZ^hl}x3|(V`9jux-FXLCKRf3`|O_+gBOP zq?M^kH20F5vTV>6R!1MYD5^_WKxW;%0f0s|LD5ka@+u`#*$&U(paiY-jv9Ky$pX@r z`YsrKYCKuJIv#_?lEbEN87~K`^!7ly!B1tIP#wYKkg1Bm&!2_jVBKs7vNu@yHOqS~ S4Ce@&p5VZcfQGXPdH(_@A(edq delta 1729 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 KZ2LU>#(w~}z~p-X delta 1926 zcmZ`)dr*^C77rkV(N#LQ5|Nk4F6gu%E)Rhi0=`&Bz-8eBhG!~Ol&2vBfkgO-6rsgJ zX~l$c8l8sr%DUXHJw+#ws-M{coHvtSF~DWTs)JTLSuLN<>bpfT8^bs=RFd)yj}!n+ zr$tr!j%CuMXPn^b<+mkH671isT=HQsw-E9aJm9m|5%IZcB2j<0(Y>T-URy;`Wy$NN z-eST9O(~E-WwR-NW$VYE8jpOfPo&YVA;y*!g*cG6{W4eKQSgBGmijU=bM69kCyy|D~7`jw}(si}eUQZfat~l|buee(i zccdBG)1xWk^1pc&y6Wky_wE=rkn(i>Zrr#d2Hyv72bg?md8LrN1P?j%e>BKPX17I{ zRQ5Z3UO2;~nq;8x8zNfI)n5V$y)CSQ(O13@tKu;i?VTO^YY};W)UFE{k_k;C#>n=I%8mfiC;_i_v%}<>FJx&;` zC4}Plnv0Vt88nKsLlcWgkgo(@8MBBQOHM#txw6^_2wNL7*)l|-rv zNK9jQ$N9^8dt>~G^+Y8vofpK4Vrn8dxCIg1y)%fetwM}GL&m0pHMprXk^*wb;K?+R zs)`6sHg4I-#}Q;C;6IC`AAQ6vUx;=;zs2!E1+K7&oDkd?{O?n={1(?LWf}wJU7*J3 zzj8_FK}BD0p(XecyaQU{2*Jjk91dVnZtLec329_3*_=ZZrz~i~)W@`d8Qqq^i_yJo zC6$F05tQ_C?b;0=lc?6JH14oi0kxmqZELl-Owjj7s_uoY!a^yLD*hHEt%A)m2%XvM zzmW~pSRb=^{byoYM;z3}RKI@}cuG;vqRb%&tm|pCwwa7_wExA)cgRJN4rg#)LYH0} zAi>mqXsc1q442;+elz%KktnTPbZ`Q;`Gy8EvEPl5GB~0W*=BuDt*3@Y3@J%Cwhfl> zlTa<%035eWOYLv381H_cX!Zy@Fe-05;BJBu2WQNEkGC{-ztb7EOJ_vJz{6cr@?O9T zlchq34f(geiP`~O$r!1-yU5J^uamF`>-*p>FXlJA23y(or1&g2*~r+%scx{J`mWQf z%d%_x6#tbGWvLK1)kT)Ah zHZu3xd7XZ|FL-OK?Zoa9Y()WrXLo2H7!*4+!+VZuHO&*S>EllPX=jH9O{yX9>|1wDzjx7I?Cxn}suyoNl9lzbsTGe> z5X}=t(+CE?n2K!+!$bPT#eWGR(bx=O#bE;gZ;Wa3`b*SfhMH?2()-?guD+T>=SX#B^U_OzYKhk4!?Q2k zd#!`!-)X3B@2LB!QSGq`2o3fou<0~5L`&!{tATl}odI-17rWO}Qq$EIUkg6koF^!s zQw{A4AutVP3?{qggX14#0|qnvZ#k8dJN9o>fyQ<=e|d@J{xfaMe(p@@>9&)Y8~*{l C4Ev@4 diff --git a/mobile/test/goldens/now/s2-min-160.png b/mobile/test/goldens/now/s2-min-160.png index 8ec9ad9b9cafe56ec7e02357b8d6b50a59af0b65..fc3e6bb9004f2b95cf5a706b1759fe1dccedd228 100644 GIT binary patch literal 4320 zcmeHJYgAKL7QTQL+ffi_4OkS>H7!%MfEIZul87)x)It?6F+_->0w&myMu;XruvV?8 zFqT$S9)Us?E`-MlArK}6t;i_vYJdPq1cYEhZiJ8oNR!NE)!OP>?M#KAo zXCfea;Eqk9?fL~v3OikPz7_3}x?z)9xYrQ#yS%HXvDJ}lTWjKMyaR2Q*?n}}X@~tW z$Zvte#lv5Ckzu}!{MSX1zR}6;wK6wZeS}`B)5itDdSlIIfg6axnR{zEaa^7foNLTM zjeKBJ4}y-8Z+!UNrZ%RxVPYM1Ym$7dfThU{tsU}Np^?O1Gt8)_Tc#{oxY&Em^tF)5##FIl?tb)CP*>`6?B3+jaQjI~NQ zkxy=_b!Q2$K#)Ws=e1RU?DgH{e65_Pwa6l(h6hoKCS3VnhSDhX-a@?F!^titwC?o6 zb86Bu^Eu=DQ_My}Ts~_~IE6LbM-Vp}SZVt}O$4Sp{v4Ks>Ef*Tm8(^e$MB$^&S&ab zgtMbPH5@{48_p0(Z7us zdCqq{{+KTTIqmA$%uFG3B?7u%D&GNus(4|tUMXd;C1s^V&leoy@;Gbl9C@nS=XHeX zj+&wV8}SUQN6Gr`S^-2TiNciO=}KYz7-|%4;#ilP3N68zQb!Ws<&t-s@>oW4YY)kk zQzzgW_kpbUJCcnU(+vI5ig~$me@yW=H`$%}IT=^o+$;}H^KNs7N%4gcq~j3;2Fv3N zP*}=cujozcU>J$5<>(i*SH>NG(0CrEKO5kOp*F!@+$b^gOcfF|YuE@utQ+CA$x03e zttmU8ZQPs*<2v|>}wIv8fc(8%})k;8GlMr_f4ylBacMo+AijDO193Ku5T&Tv|rBc zqt#WF(keMdQ74;Dik{7yW23bo7|h(Fk0Bbq@1)>JnGh5@zDg@ug=@Sn%}R;_RRg7R za(R=Uf|;hx5+3fPcwSHq0`rwVUk3lG<$>>OKU!C(GVaE;p(0qxMKm#v1+$V?-n=z|KCHnkfxK&z2U_y{}Db2AVl^-rdqq9h+P@-~X zogM4L4!%w~48yldW=NsJd{Ak&KSLo@)QTd<16Z7_lKmB5Z_`OsPwTw#Fo>>9od(v1 zamuTvf3m|7RhO7P`x!d%;bxWy1Ic?vML>Q8(^=nMT?f;Wo^<4&hWXOEe(|&d;xV2a zNbzo1!JZy$!Vyhd2#;eQqv@=UNeX6UPQk=XqpGU0mvy+gsv%r{(}hw}M4 zFMX~h`^}znQatl>eC~Li*<+IelWed)*D{?UH44{I4hs8ye^zA1ySE*lhMIP7l(+xA zg89qn|I%p>sN}eS39>oRvW?q4$3CvA`~>B%l5&xP z&b46!9{$W$yYpd0Qt-PUOVO!BXlVW zj??vu4A~{G-F=e!F=Py)C6WEJiBB9z0`ib$rkaI_h%*II=KY7tT1V>8{k>GY!^YU2 zj4zdNo#_x(X$KKD6Py|xj%+T`j_)Ctb*9r?Z7=Qu4YgO?%;3d_+UouP-un6APU9_! zq&15Pg);VoxRPtuOOKS(l4wM_K0UxXLUhX|At52C1!5FKFji=l;!cmoDi2?596Spy z#*4TjXWNT*QR>D3NUnqA_aH4$diydoV-O{dUjZ9ywF*izUuW%1r6Ti9iJ*ftJqK9- zj00&KMTyDzoazbJ)IOc%E}F`*2oYy=*9L#jFrujv_Ip*)HTX_Sza6^Ux%O(J$E+{# MpF#d*+Yf&6Uwo5lJpcdz literal 4725 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

(VcZN7%l?aRMR<#kP=II` zcW4kCtqxo~D$IQBw{#i@Yp@5SEqA7UnOOfjpX#Q@&2mjM?$5Zj@gBQhKTk%zs0X@| zi&?AA5w@$4TuwIjjxSq05{F`Di@6*QGEY*jHU-0A*ily^lF<5I4f3{nomi3Y8otxr q|99Z1^=?+1jlhiZX2Z58131{HElDC~nwGo{K6d!Tq2`0<{`Mb#b2%&k literal 3480 zcmeH}4OEg@8ply9wZ^P*(o%}%xMk~BZkd{zkXorTqh^LCn)Iousi~FgA*W-(OQ#5`ktjplNx=sAjQjXzlpwNnjE{R zZyTiS^YH?ew}Rj3jlBGJdG0?`BoPEYdYF!B9+SzKuIcUEYrbDp^?H34>-)5#x(8_= z@O1dz;)k0a=2k5$B`s;(onW~l#`oN1<^%ivd)-z=T#7Hl#@;BrW@*0yarKW^t9k&F zs84cm(|ELWD)nmE0gE>t%BkCEn35@`6tR^R2Rk(SABXb`aYoKCdNp$R=#>}CrZ=}yJcxb(q1YI6S1%pgXmSM3uQJHms zFpHZLsOnhed_1jd38?35(``F9;1-#zYXjX~V7>O*PNQ=lpUB>8XtwOLVgs+a2Iu1E zTE~Ce=+c$kK^m{FP2t8yNlH4&0Ee)~DVZ02gU}#v3bu`Ufh42&9*LuxFKAN_a*&tV z4-XT=6qmZUMkWYD(nHx_*|=#lTKG~_jx1U}#mM?uiun!tcNUz|1CS+GonM}J=7C** zr~2s+@0QV{VwZq3pKnSRxFr(ZFOb%2U@=Vm$-iRm#^#W3h*=NPlOoj3Nip2m5KJ%i zqw#w~4X0UM2jSjlS73E!w8(Op(D)y_Y<{qMXFu{of4o^eBBywCy!7pU+g|_bIwlR( z|9DuPII1vwhkWI`zbAgibWHSr4Xv9V`P%M|wNt+M^$cz9_)qR-Iy#&Xey-aiEw{%F z?yWbU*Dn78a>2xJA|`kPap>~At2EO`s6kSU-f2E9vy5{|WTtQZiD2U*fk%27(E1qD z`%bpu${Hd6McTzaKs2^w2(rC>X}{eYYxBz1-!|WQ5LbUni}M!9Aho(_0+jnz7+M_n4gwuO4xEyGthZ~D5Bt@ z5XO1U13;%ZNgKB!QA%A<_wp9jDruF7&9HrJSR~vfUbF+$-2X_m9Yhi7ezeiKjO1z&T@XOX zO73sFJ7ZaokYXx<1RGbYo&}RKUgnsP(`oKwR+MMmjz*3+D7BtNIB`2!|3>RXBd=I* z-`GYRkZVH}Y54<5S-veVe@s z#E(k0xu9A9Dd^~2!E6qp%rgxBmn@xN(uE99%^LzH?VBQpO!}D&8j5JpYx3K-de5d-`RPo4y zGN2c!Dg@K_1nWL3hMi)$cVuV|e~D6(1wq}@`LZ>W3aUIB!xR5A4C^9ZFCbn4HP zNNm>*&tJ?}?>6e!TH-&KD*w|O$uoHyY!n4LiGLLjUOo)@=%ztM{Ca9qa&dSnZqekb z*^?r2502J-jAYhPwaxbj4T`okC}6Sf?$3oh=eMB^L*w5g z0TIF4)uQLvuzAZ+sVO_|wi)oihCX<8Z-mNB}=@F1K* zrKYnUtGYwkd3ZNt#ujuGT#{~?`{F2mCGAOIW%x$R{FcF7K2hXA6ixZxHtt!u0|_S- zwpdXU?OQ3Y?1l*(btSzDA?*rhW9dMaVneHGg+X^&EY=avqH_`a$)RPd{fe;&nI=`y zn@cA1Mx5B4ao6Fw_aluMo=oC(wmL=sP%2X%QYQNPA}!u0mk%n&*va@wXt?2r<)n&$8En7Z0lUh*4xRlGj2rsk diff --git a/mobile/test/goldens/now/s3-min-100.png b/mobile/test/goldens/now/s3-min-100.png index 839ec51106dacd52c5687f905265b0e943c2c544..c99a2153b96947afc21bd527fb64b05b5a656d15 100644 GIT binary patch delta 1702 zcmY+Dc~FyQ6Tk!ED^;loSTr1g0rf*DASgmq63|FFL?kF80issURFNBu@M2XOq@e8! z7(zrUBDUQ3m2i9_;fjWu5U!9)q#+<66GEDcG-5l`Z~xeNcK4b6&0c&%so?{6z|srt zem0e|#PubHM#6el1+=14-$T}(9wQ#Vda+Oa42y2uu{FA|APcS4d;cUV{|WIy+Zl9# zv)w5UhP6`>=}ZBz=DLbdR{81`0=hpiM?=G*ha|mlx`kZ-WU_=IYG%!m(3cMPdb1-cVPkoKW8gToQuTeOcD;4q=uCh7t*1dB=gGO!uh$y75n}>be+)1zM|SONDUErm@R~MxH$7-;8%VVTIcyw z{0?Cmb;%0w(hQlLB1W_wwa_A;8{8xW|3i5Du^x;lo?+~&jb&(VrVq0Dzczx(6Z=IY zSYI`uA?y?2YEs9&Z*~d{)IF{;Rb3%PmZar4bZqr01>;RY9`rcaqorlU7M9%EmUKNL z(Xa7v1MkY4W1_$})KPpJ`>P*&P5nKq6HPx1Y@B$_!lc;Vpf{V;lq1{5mtt(cAJ%WN zX{IQ8`P$3a_sRADgYz+C6X8<@YrBEedA!e!E>x3>+3NkvecR+twR13*4Q65KA(@o@% zsCV5?Lk8!^bh`)&-PYF<8p#KKcAvovK57F9*Q-M!HM|T`+&TY<&gG{#NfX1*w`aS_K0|5E-Tn>2k1h zX|Rhkx`#=qP{rf2Wr^rhO(bn*<8acMmB&(k3r^Bb+!q_C-RKxn$5?NHvY^abr4=WX(^g+Kw89%;DYKHq}FUAiLl&EQY!JWQl34>fk8by71BJ; z2PeonYb>tu<=284uEeqJ?n~-Gp_~*EW}I^rS(K)0WWN8_XP(}r8b9erWj@vVWLhi3 zlT&7Z1gr@{{Bzesjk@;~cj%sib)Cuka1H#Uga7)uL}2m{Nv3B3&%Gl1)asV2m$%TP z+Vg~%^*L#wydAtCJaUn@CY_Af50RQY;!d`MK6AF)H%Y%3-pZi$>SX>9CQK06YAWMz z!yrPI6>Ysbq0}e)%~haK^TBW_tC6%_wYG9FI%IJFT_;GcJsa5P_naF&7913vk6j!9zS_Uf&>8dFLN9yW8G^cL2Tojqpozz?hZb4uMZ@>>F!G5NS&KtwpSjRoUX}8` z<>e(vJoa~b_r1E5Kjp)+)mzPrTQ>OJaKm)(Mpz8>jAcn~@MDBb(ZUncto}aCM$+dE dn!7U#7JxMS#DP3#D*R)>>*Q&4&GC!h{RIwFWcUC8 delta 1683 zcmV;E25kBH7ULF>L4OTNL_t(|obBCDh@58~!13QR*`OsF|6sFdE>caZW((~K&8^Vn zXlY195Y!&hQY?xTnjRF<)Qg8k@TNjdq1BTlYeEhdN&IuOnl;!2LSl=8H6|6c)li#k z=JAjT6xk--d7s($x0}!Byw7i**>~r=!^}JLMgRZ+000000F${0J4`IDbRF=}o+oR^ z5yv88+qjEME&FS!yT@H$YxzYf_1+Hazxl)O?D^KX>mPh<-#w+&UyZxI>+i48jQNO| z9e43cKZYudRVTZ}L42a~J>DU)yk3zJ;|5o0feF3IaPh*)^DSmM9^JJ(9?(^%vp7aEINEGC z!`ks*#tNHv^>?^EBANuj@#wDQ@qn(J2a{0)FOmcoUv;`B;X)mp8ePM!jqDc{ozO)} zTh4Mkx@mbl00000006QoT^6s~Ff%<>hFhBLCi|O6=H;3g_Gx7&4Wxjoy9SV z^U-Fz8P<;fGR(WF=3V_8+#V4E&8p-)lfeWOlW+kGlU)HGlU)G`T&mMG2^Z?%)aaUU zZDhYlsd>5Pjs6B(%>n=b0002ES(Gk|*KL@Yo+`sF&32RhO)2xtTTSyuZ?ayzaPoYs zd9>B(ERIo}k2c%Quy*{HVV-&BEyd_8EP+nSd6V%3E0e$i9T;m8F4V!P(KXH5$bOMc z^UPaK^G1*1Rux^5-iM{*kUy#W9gCzJ66E0O{P7ytkO z000008QfIYBy_#Lx8_=QkdM>s0F$r;C6jOgACb@oe{P^ma`+$3OCF-d`%mqEBkDw7{(ZZeZQ|3jXFp#fo^3S` zpf5G*?z#ERPxrl#CV}2L`^Rl1>T?k>)nXn%&o0+N{nXs=HvhcOO*DClV^Hc>#2Hvc z)(&dbJEVCNrfAYqI$3JnduMKp52EFFe!cK7pR-sad zQ#<%mB#f)jn^$MWBEG)?fxeIyFkKed`?P zQsP9LtpMaQDD`x&x8HR@t?|n?TLH*rIIKVG^Y$A&gC6#J2LL9e)+;MJ&5pKRxmBUcVHm$!T__3?Fqi2e68~Q7^}r``$#8Kp!}M`{^3>kruN6`c@(y zI{1f8f9ZQ4%|3sqGB>~Z>46<6+j-~N9WjV!O2qos zvjHZhM!XhLKQ}kO`AFY;xu*F?z2{DBIy78UW=cewI|*<-CF00%O_|9Q=a|GZ=r`MS z*O_~wlzU6W)?t*}O4N-J@vceUCcq8+CrbRa)OfK*JRb_R9y|E^&A;w@Q?s!MumX|L d3K%QZ{{X9<6^UTGFjW8m002ovPDHLkV1gsBQt$u( diff --git a/mobile/test/goldens/now/s3-min-130.png b/mobile/test/goldens/now/s3-min-130.png index 528b2f9414923c4526d95cff926e0b7892e1fb48..f484a6bc6a80b667afa8f6dcc89378224d24f4c6 100644 GIT binary patch literal 3430 zcmeH}c~sL^7RM7o5NCiQin0V0h2RpVG~dY5h#lU5?MlskfhPWAN6!RGpFsG{?qr*yZ4>@JHPMe z-uLc%+5Uc>ni~x^0ssI_Z!dTt004{v093YbP*bjGp_gUKNfmR@8@WL_u537iQ~E$m zpyxqABgg2i@}n=j;cm#wq*>lqxur+-AI(kDOHnxJGp5>}$Dy$~Nb~fPM_Q1Rf2|Jp z^|g8S?)DCYung;suYPnrE4gR9(o= z@zZ3IHOerNWi1OU4J};EmvkAQ=p-sN1S)0x*74{@AWD7cp@8#x(gF@2qOJ5zD8TWF zPdL2eg@NTWj@HrA*n(u~U6TIX9o%vhH=Rmg`H%;lxb#_h+ohB{lfHyu%8)uB6unOK zn*vvrU1psCZw(vQz+dV9N1m~xC}U?vs-X6EZ*eLsXviYWog^Q7$(ngi1Dl2+*{jcW z)${RK?RxVg5N%^bDY@o_*oiaBan35|C=_5A2!lS8#oU9$|2A;5GD zM;Du)AHXUVOqQL9`u<_!8j5dy_xzi;;*<88HJn9dzRhQ8JRjLf@lCLnF=$e&-%SX+ z2k!^UY)D^{I7(xcDQ51TmH(KX>B(YTFC@RUYg2AU z)W1)4jO`P?X!fGA$~0c<>fF14t#oa+_+#o*TmZZGE%PJcYlrU2R_ za>yax=MN8t=fk8D=J&yN;KN4FP$4X`=OU5kKkW-S+o)tsoCvCZtqfV(*Y$C;;ff%g zE_LgEkCXkGuF#8QXL?d!Xa_{DfF}20P3@R(BX5Q;4JbwU+ym#~3j2We zvqQ2IkOb_4TXa$dAvr}rLa}3OdYw>{YhA6Jt8XuCZC<){Bsz)C;$o3(h&wq^;&2!a ze_$0%Xee>F4f<1`hTY87v@x5E@pYsMLMHCZ1hr)b(@2amR`<&KZ3iga7bL2+r`$I z3xgZoFum!G+;o(Lo&k8hp&E=2)B3dd*yVmh zMIZLE#8otKY1vw59BdZWDv3$Ujb=XI_Oxipcwp6^+v464Kz$MC^ffknp5mbLPw(to zmhLm9jMaCvozb+d?L4)VOM~B9_2Ep!CF+z-FYEe6DG}^USVV%xB0ko$$uhts4@Wj$ z034$nNvP|eNl9imq0|$Sa>6addT2Eq4o7u|ga{7QNw+LgkGmue_mXM&ycGg0ZA-O> zW5VMhc>@cTWz&&ydf^$-iK+hvgaCaN+z+jG?5ru^(l~%yHn7(NW3VYU^o7#?A$CS5 zs(iERa{LEtDghJ$@GY6JyR^D%2LRU4iY-dKBF#k0ubn4GoVuSB?0oe{ajpJIOBh&7 zA*a^r8(N*-%9Wv|+^(_7E@=3ZYv;pxj_wPNa*E>RfF!3#A!0?gw40g=r9wV2l0rWg zT+y_=hz)1^t_)5PUAp8Pj;9^t&f7VvM-2Upl&e#kY8(`CUlrOT78AiC*>@z2Zs+Uc zV-T(XcFA?8e)n#;JY=&8RKWv@Oj!Kfrqc_n0~}RK0X;*_YtQW7DeH;yWZSq7=~IQ2 zKa|Q{e*p8e#V27`n%T&fPw)8}h1uWZ55wjVtI}8YB@xRWg^4Bi*`xwMY6|#D_Ys$yT5-cEGb9jHq)dXGh_Z$SvQ85Ffmhw?`Ifbae#T zGq!_xbZZp{&h-!%HCRLG8OF9I>q5JG-}lX{Cv)XF+J~4`4H$s=q1|(xT&+{{R|F(W z6Xfo*i<*fT036N~h>=$qtP0EY;bz6CyY2K3un1uN8MD!6E&N!yPks>3Wtt%=hG=^* zL5vAty-f|%UgF#=W$`A&1{a^<+4xgi-B&4aUrCwfHvxGL`T-pjWqAG~X;7ALHVdOj zZ+De^eJ*<{**`58!Q5G8JKjBv#-r&Osco;=+96wNVytwI<11tK1z6#5k~|Ff(Kgv$ z#45G-@&ga&EmRjf$slBfu;TxVHu2}epBeaU2I~D3YVCdtEqO=ap&x?05Bb3x4~Ay^ E1i#$De*gdg literal 3467 zcmeHJc~sL^77ojR8^J_s4*`M&WEoi}fJhRyMkqu2+F9T8saFj zN}wu1i$o~}WDi3mRW=a_U?7o15)49`B0)fsh90#&<8)?@?flpG&%5`Y-|wC8yZ3wd zzN}q=etK&lYXAU%p8sbk3;>`J0|01kTBW0o012;_)tffa$A9-K^-5j!IYsSj5HWr} z0A`QzbM;Ry{wVL=$ycYxcHSP{W5^nx?;8DP^F|{*1G}}}`@TD~YV)aL)R$H^nodrA z_hKOCZR?}H%A)ExR@ttx-*0XC7i~{hh5=+FSSvU*;HX`ptKJ0Mz}nYl;D^5B;(IJh z$i6Me=$SOxsAS~sxT>YRu3u2RL+rUhLk;8iE>;r;t9x5;cVXPa?-R4bow%(LeMEi( zp0B*s7Wq1p4vS|i!BwMO2|zzKxgpSYj&4`fnuH%J=xQhc!t7;^nX7HePL|FKg0qGV zLGfKL#03<$atX^F`kE<0h&v*g`T&-EwX+vSQ}1GqmzKrmM*#m1ad*#>z+9~T6w$!) zkQ?!(ysxjICEYJ`+`8lX*9FaA00YmElTf~`uaL4C1ac8xL@p>$s2q&%W&XQDJ1zxu-bd$EV&U1t z12Q+xRvaE(&7sGRZjg=ct&%@Ih2&W^M}HkdHa%9^j(I3mAD6`tSSp62dY=utU!}UN zJp*5@aS0rgaYGxj$>x;KX91;um>b7&&~wJh~3ZNFO>}opuS104B<^!c&YN9po*Q z#+&3~LP_v$f9GY_@SEf~lb#MBE%O=q3lYJKG-ILtN0RteK5i8-D$s_5l(Dfu;x!i# z+RQAD{Rx>r!CWcHIN>5WsEaFlO9{Zh#MUQ2|8`G22UN6ZB6-D_#sw*ciCvoxi`ulv zwLvNRvmyV&rHVtp@G~2T*op+!{NStcA=*;x=%iC9n1?Zs;cqEqY<#aOVIxsAa;pAUo*yU8Q%RGZq-%0r$2Znr#OWPRx48)mP*;Fp~l+Cm0y1T_rK4 zCm@B<7;vtHghKpyN9F3N6K9thRKQL{WEk#*Lsfn-!1C^X%La z{JrSqI8+lJ3_)p-9O*A5FKPf&9P*v7bk($f=H$gbxf0b8|dJ+ztZmfUC zEuouiamdfk>*k0k-l99hP#EI@7k-d>P&tV&PhCzDAYmR$3&y6~iPQIkdgeee&q^%+ z+aQgVSu}~-XdQ;8CrO;8DYTy`KFkAN1=HxV?0qB9$1Q9^*Fh*5me7)*QoT-3P|AP2 z_w>^J>LiPd@NvUmX7v@N%Os!9Be`{mY7L|9O0x3iO>u<;#kTXpGtt@3-Fs_OErHJT z>q#BLejRxm>}-{)K15Y*>w#O3&IJmud>piFZ0b(5JPYe{Ha8Qp&g0Up9FXZ23J+yy z|Ap$F2m^eJQ}&S3!+pXqX@60V7jUipBT}mBO1+0>$O|Pog1RI-k;J66<@^BFeMfu+ z)mr;-<9(#mCLTf{9ZUD!NK+}PulREgJ#$W_MK%lNs~yuGdt@qZ9b@@Oenseip5D8k z(2R-=)Mt}?bMbT8lw{?D_MJnYx~lQD3>xJ-q%^VR$Ilr8`o%R|4RwJN)pkgkZ)`#o zu@s$kU_}qzr31~~Fs$wXq!m}J;fU)HElzO_P3YdVB7L(u4m+Mef$*8^-s_~fm+UMN)40UCRVyiN+9tc3UQ_Faz{irUCZ4K?Em<|Q9F zyv!e*4wd2HVSE!%-OEBfO?n>nM?C( zr}r;J_STPi+Q#yjAL(B>H}M+WH&+TUyh73Cv?rcaKP)Wh-m1oU`xIe+@Wbc;cA3ap zl6$k$T)2FBbhV?miUM}Xg#-;!IM=v-GfVvs{UnRK-tQKFM*hseZ)L!Che|uuJMD&D S(x$(!eB-}85XJO~JpB_ii=_Sl diff --git a/mobile/test/goldens/now/s3-min-160.png b/mobile/test/goldens/now/s3-min-160.png index cbd07cc0d80bb9adb810978ba946026664b52ef5..a99d59400fb55f45b921f3f55afee7cdb19e0c91 100644 GIT binary patch literal 3847 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 J)qZ=A{u38o)cgPd literal 4285 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

MJ$k=~nXq`ck9r zoSonJWZ&y(66oz)UfNosJ{J*FE#?9A>_Q#X&(HpT<1hPMM3c8T2Bm&WoPky3x09;ymRS*va^a?fM?Q19uLX2|1-<6{lST#}f7W0IKt{dx ztwo^yrXa9d_1d=YUO@vSCC~s#rG1NIl1~5me7yL(H^(~W!#7SvM2&yEcF{2t zJ|;%jXxzsy$2HBoHpg5t?)Ay6bRDo`%c0)PFt1~UNCsZo0ETjxNR5=YxC1t6C}sV90pf7b!E#;@Bf1t6EPXY z^EY?}J?!-f08C1)7gu(m4;;DWL_|E-W;p;kloE%2e)QA5{!ySNx7n34e8}A#z$)@q zy%68*dl5|nec;F~Cu-COTg(FJTZ!0p@Q)k*()T)={ePm$?EJbXa_S;Tvi$Oe9B5r6s8(>mu#2XRy%d_(vpY3}smo@)Y@7bdp4h`3o z%_SnulLWY$67lSCP1&3&&M}Es&~LZn_LFx-DR-BMJBLwjE>Ulai1$tMoB;3QKT+bZ zrN+xO;tYkT_3*(zY<#}&MFEk)7)|8XdCRL^(|hM4V(X$$Sj$W;b?0IqUu)S@N*!yr z@Nti+wak<;))WzkNB^$$=e_5N$EP0~=Nzv0;w;DW-oJ16M7OM?f6X_I1)vR+p$Rrp z7+>i+VAu5Cxro@h=o8j5Q%l{s*vHpe_LNc|9cNAB9#dGZcWz7 z7g?OV6ZTq_Hu>}I%bqND;Z@=4w+CB3y06`KJo)(cu>RLAkGF^I|Ml|bLG$F}_BZYK z#Ap95ee+{w{eHjVKVe=!GY)+;|7F^;+GzJnR)!7pCL1tnO}1rZpSVyszF}?Pa-Myq zzwh1KqxZfn`s|v0;r}$3Uir4^`V{fpvR|+Mq+Cp1_v5o^{I!~w*W-WvDtq%aHr+1s z!)x)!uG_Eb+&;3s=l|^MPZtMyO}9_@^K+Jd_P#H#^=oUbzx`VGnEm(Ug-jxo0~oo0 z*2w}fM~Z2t|DkR(>)5v^uO1i8eRZRF-3t`MSs8$!;dKbIqkMnav!0o5djHjYllABP z#r8gHR+&8SQN+=oP#5rgetV^6{rZFMOGO&fv@7{0>oV#BEfW`?{7q#~#^%dc=M~g0 z$q&lfx8`Tq^@R01CoYurZO{!{?)Ty4zlZv3x3M?#-+2|d`t8RHg=?=awn~c0{raOl zot?kEPg}aIFZr5f>KnDWkNex>dv@KM|3 z91M0`@q6o;v47v`o2U6cRe)S$TK^y@dO!c&-PPs$@|V__RV3v4d#}FFCm%k4+BLTR z)k|&Ux1Q5Xp8GiZSg-wYd-3%7{C4;HVpiAdp4+z1(zcRMN@0ovbBj<$LjV)6K-Rs= zefCyoKRYh3WH6g%4G~=R-CZVM>X)JG`hItYZB0;7uLX=woF)z$8I5clSAS1mdap(( z`BbpXz7VnOe{FtLES*yR?cEl`$7Y%P z_r1P1xghLTyT17T`8}3ucTAvWguL&!s5!{8K)4(j5~4D(Pt7^YgXUiSe!NDx;H$4? t!~Y%4Y#gd!`!-)>QDmODfkQm+*nY!J`&q-EJq-edny0Iu%Q~loCIDn%m*fBd delta 853 zcmZn>=n|OV7`4UI#WAE}&fB~8`O%>=#~&8!-j_64vT7~eR9(!W_iu+ zIu)`qv0-P*)oNeMPf- z{oNgV&DWofzT1};X0Q9+OLObqcc!2Aob|gmPgcL`OqYH9=S|O+#@D|*zCHQ;d#lf$ z-S_P!-GQoas_jdu$%&i(-Agm}U&F`e*>TtQe0i<^_ea^ApRvdJzfHDdR-F8RQ7Jj= z(!7@Exn-MfODzb7 z`u?(KJ#%{2_Fw8}Z*P9=y1sHk%B5JD)fV!l*0WM)|Cp3`&+lw?J?}m3r5q;FCu^a; zk_GyVTYT0Q^@o+S=dStI@>;oeauCq@Yu6o&+dOfhtWSe(*mAy#9seJ#m{L^!fq8q? zjjY}FC9&CGH}pk>dsTk@^x)$P*%Rh3Wsb`{pR#p!NuNHyT>q|n@-|i#QQ6gIC3n^4 zD$hg>NkMUt^MPPO!t!q_TjKB6Z=Npuq;R(EDGgkP2ms_*VH@>0JHUDx-! zGi+-rzre7NMM`0c19OWIPyx^_>`7n$z1+{AKXrD|?78_uOxEY2I$@@E|IzL1xPNWO zo7#I(+VT4>t^cNecsw=8?&j{B$JX3l^7ofswf~{W%e-?6|XB1rB)6B-93bd!H z{>a0BsSSR6nkQdml#q#i627-QXztbX$7)imzlNqY)K{{@+`joDiwyI`4crn3uKi}O W)ttpuS~9r^6o8(telF{r5}E)3j-x05 diff --git a/mobile/test/goldens/now/s4-min-130.png b/mobile/test/goldens/now/s4-min-130.png index 1acf1301a7140b671fb52be601b279a47d27555e..3e17d470d45f52699a0337658a38c6cb2b51ec20 100644 GIT binary patch delta 1022 zcmX>p)Fw2+F?xfii(^Q|oVR!P_e}|vIR5du&x%g5mH??-m7oe{sXfdqR{jb0Iy~uu z;98#;DUH9unXAhcE=q5>k)qwJ8Yq33^-Xf4Kv$`7@i`v*W9OI8|2(JidF_7VnR92Z zWtzCq+2g@dqcrg!A0B*;+bp_j)jpd&wtr{Xyl;+ff8TS_eqGGwsy{J{@BfQm7x^_h zbnmvJgYP~T^z7SJadvn7UF#nuPiMWk_slh)q2co6L?+qEeJtt|7l|g{HciW``FH54 zx!L0NYi^%=>9SX!#bz8nh+V;u)ul#e`Ed9;xLgV9e(l>v5@oS~*-T9m6%-;Di z?aNfT$Gb~!Ti(5#KJnhS1*>j_#IIkq)=uGf*_|_n&sWy&s>^c!{`}2P2A9c#%#M>C zn8fO>%!?mSmUEx|>hP3&&@ZLs{GW!df-4?h|npVJv> zy~B@j;zIcprrw*ZcDqV{IQQ?f+B;D${`-sX>DOP%l^=iMpLTuas$ZWzeEagT^3~zy z^VLVK79T$>di?j2<7dyl_^tgdICSc^<)Xh=wmuJjRQdDZ+Qa3?UdX3k_q1i-x1;>S zy*F0+w@@NQ#vkHyFd30_M(@MLwTI=azFd9T`{lhX zDt0`onEz$RPRZK(qjjrRg}qr>z32DUz0LE}t~b^`oZT{6kVSEF9+Lnt!nuNx%+@iC z`@**E?bPn+@5`?qzs%jcFWA;@cI>j5rtc?G-{FgmTR z>waAM|7&hnXnsG#8`kweAx;wqjYc*O)e8&@!~VNZeg6M~`Db^h`OR$qzVB(8tjH`< zpEY~`(TCej8Psx&F9^o%toT_MUTd@W!VBxGk2l{;-SzsI#MiT5>crMvjWw71U-;*3 zm+e|Bs1v_fh4TOV_4miKH&&sc>*H!ZubnLAuKjJxlQ}Upd`sp|H$67@)x3Sr!sapW zex}RU5U@Jl@i&{o0i`%c<`$uhh5#m@vnO}6ND5T{PxjpKiha(m-?u*1Z{|tgY{mA2 eNs{41(J$!*)B69n=5JO6g`}sepUXO@geCx}+!vVu delta 1095 zcmV-N1i1T#63G&fL16PqL_t(|obBB)i(FM0$Km(PE*3&GHf~`hR<45J7Z74Eq&8JZ z9YHn{Y{VuA8wFFxfTFcvpMZVFYzPqWp$=gghC zza55|J5`er21I2%R@&%*b9?*O=WKv%V+nzAv)PxzVNNfG+*^g1H`nIZGHFKIDtLpig1N(CE^~`x3 zkHv9s0D$`wlK}!bk^%=UYWOz^dvCAJ*$S($wf*OJhktI*eT)`chqXN1GIQ3<#gnyM z{yueP)lZY>0g<5;Uhvsv0~3-03x917-MY2IUUhTYZXUEu)rqRAUUg^XYqfT`>NPg)xLLWx0tYH; zd+64!9rmi5({}TqWvWh8RrRVno3Kt*Rcq~V)oXZstYe+QwbpT-0+FE~TjXh@1J3R3 zU$3fD!yD=CT5tXA=$zi$d#&~NN9R;kUA=r^`1ss)Zg0O&8g*mF@m}_Je$o1sN&Bfy z-IM3#qnUO8dT-a-gyUURH?Cg3@cM)~$7*rh8vx+`lkWl~k^&e=0000000000004O0 zw9x_Q_V%w=)v4i)^meVces*+D@9n+T`un4EX3h%T)3r9?cvsbpzyA(D2YPR>ee&Vn zH{)A^&EShFlh6V_QUCw|000000001Z+_dA~z^a|x+5NazogW_5`o-$)gV8a)*O}Jp z^WptWoHcV#t?J5*)>0u2yKpaDV&G(f1? zKY4ZUCox7o#o>!8!2h0C&;X$X8X%NF1B4Q2fKUPr5Negz;&5#ZfB~xvbcB;TyC3(e z^TUH$zgWF}Fgm99I@4NxKD?h@iL-jIGr#?E@WqThJ@xGFn^W#zYZt5c;p5$^uH3$L z@Ye8ts_L1Y7hhgh`+mlrs;Xu@FI(@Yduw0L*wd8rWb*TJ*17WQt%KPgDP9Y2aR7vx z{R-L_OaLbF3X|{xBa#9H5F39rJD7Dv!`}Pp=|8g!N3-<>_yQL^#9y}iW}|p*1up;q N002ovPDHLkV1j$gIB@^~ diff --git a/mobile/test/goldens/now/s4-min-160.png b/mobile/test/goldens/now/s4-min-160.png index e7f30f1aeee3a849a7cc674f333fcb566a0b278b..7c4f1bcb8f0f4483aacb7cea32549d75391cbfd8 100644 GIT binary patch literal 2630 zcmeH|eM}Q~7{?Dm3uJ&J+uY=(y2x+=Uls_XTDsL|0&MJuX8%iN{@@KRrWLpcx!)xi|{?i?#!`cc9TzeU_OYE%*n+ zQa{i>ZM=~`c_%>g!l@!84&3*vTdLI#cckafAtWWEvr9nV)LuQEKLTq)F8>fmFH^VD z=*OsHk|Ey?Q@N#N(43FouSRpMsOw`^V4!n&qM`qNQKf^#9mXimw4>2^4ufdS!C~{J z&#c<+2?ZpBNGqlH9$nYk3kcRxGGxYqK~rH%Wa_3(VrV0I4+1fGZ^(WyIZ?<2>cG1xd;cH-3OVmUYNNrIG-#+c$;p-3V7QpTgvY|w{6%P(MONJm45m2|9YRT* zOkEg?1fJRc{usD*Quhx=R4Y}uahv)|6yOv@v=eX1dCL1STCi z!>_BZb>x_t3-Yl%S5mMx!)3g%EX)>UT*-3OgEa2wC751|S_Nl&H5FCFX4j4UY53Po zE>?<2IBH{!5J}ahl;gzwxPN%ydhyS*=M~sM1#U5HJ{PlDqJrB!No%hsA5Kf)#UDTS E8$R?QR{#J2 literal 3394 zcmeHJYfw{X8a@$PSeJ`ut)OyA8??A%5fL?lB&Yx@*ja6M{G?&UuhPuhTMWYz$JfFpFE z4sNeoBlN4JScFn>n||2pR$}9kc^zfC|3>jTtDHis;<7)Uk@g{+P}h!ZR@rlDQ-bhR zE-C4y7M)wDi!p^i`rG4Pb4XlkB(cRn#|E=2)`BymFMQAEQ(5GNv-6(dgJ9nDMU?FE z_+M;evO}IS&{E7=Q0`w1rq7OaIiv3O>NLd*EmtX7oq5}m8m$#KYJ=kTg+Wb%zpakY z9||+HVJkI<{47q>8Da{h_)<2--EiL8{(hudE%1w8++jFNt3DJ-c0*+!yF))t1k$9= zJ%H~YcJ2#YW%<>OeI}o5{xE4jVHi<0-u{0OPNY=UB6Y3Lns%$NQohk(lFM3%D8f0U z@Ru2WYW^tn)Z+A`2aeJ#eF%3-T+p@00qn&mHI-v3+b@jGRk?D5+DH72i&aG9@oJ=D z+Q{u+dwit7sLMvB6Y-6Yeq6eB;&A8OKX8?sdic@>p|@DNTohpbAyK~{fiAh^SiXMP zKAIzqdl_rY>;u87c2>nmTmNiFxez-kCnhUu&KrYfbl(5v!akPWm4U?ShsK)RXbM&# z#Iotj60uqq3F{(j78Iwo%O)qa3$-$Zb*YQi@8)iXb6vZ^$Rr`5hbxGm+tvSj5b2#h z`>>k(7+yoJK!0e(-;JFSG|}!M4=2@YQ$+0eqn&mYcxd2sYS`^;N`y-b=9=QcwVMAS z2DavOHqSrHp<-I>)NFVMx?(A{LC9JPJmrldxZJg45tH@p|Zer<|1tU4bG9$do zo89m2E@ZW6RexX6+FbLgWb%Vn_PcSew9qeWymBv{^p(sjzV?pT@>!n4^g^(z`N_o3 zy;zZcpntZ8#FRsV?r$Vf>iP|!KvU!Z+U2>PoWIa2PMGSB7vOk$35k@Cc0&1uv{4#% z5Cl%)$fxR|^%wst-|6i+>9y^22=8XUnfmb5kRtm|LTZs^f;c9fbH(G^cHIMFoA$8(h#j$LSGKN7^lJ zg9kwn@73OD*k#X|>jiNsyBLd# z+f%Rk5qU-X@!UD#{=5#@Xt564(WfBws5=ewG&zm#GrIVwxNnsGPGv?(zyIAP)T%3yCJDWpk$Tta7aR%ZJ-f@z3Q821P z|ICIfRpTAsW2m>2zZHzh?m>;?eWm<15l6*O-;DTRZaZt8)gAX^ci+IaCUzUXQ$gaj zzp8`M@d=5^DM++_yV@4*uheJLgW|bYO6R=J&mpkXth2fc%g9E^<$lfIpTB z-%EHuSpvODpU!o!zz|ONNLB7Md)n%$QEf_k(LXH5XZR-yqB9B=?7D<LXg1*?Z2hti>;^XjO=Nrr!Hs4}0y>x^(Aa;DXHg?e6u| zSZ76pNQTW|qM*>W>9m|r;ON8;hwz7d;+l1R!W#Z^rK)<2%7l7YKJMq#@%j z@3WP){R{Y2MpWjrOztu3{mxm?;w2*$1ZQX^?w4X0P2V0K=}XkhF0xXg46kCV2|9)N zgl4%kxI>6%PL1Y{hS!7|l6dfs7ws;MN!Jg3ucpwJTZfGstwd_L-z0At)LYd3p8U?h e`!SHVtl-Wb-hGJPIpDUkBf$4CuKK{4fBpxlYrh=; diff --git a/mobile/test/goldens/now/s4-rich-100.png b/mobile/test/goldens/now/s4-rich-100.png index 25d37de9441dd1e1c82f6108fa467946ec0bf305..663e13df402c573c96e05a6c0ec06f9252ca4d99 100644 GIT binary patch literal 2251 zcmeAS@N?(olHy`uVBq!ia0y~yV9a1(U{>H@1B&n!{`d=|Sc;uILpXq-h9ji|$Y(4L za(7}_cTVOd0|Q5?r;B4q#hkZyH|EK>${hbVbMkHx2VwJ)5>Jf^#tA3011vo`du266 z!-5o_-f{3OlI@=u@qx+2b43Nia!!S%-2Z})I87{=@VzYef6e!wSIjqWzW4R|^3QjD zvw=2I3e;aGtMAzRdxM+qf?tkbZ%G-}1@7H8T{iwI|F4e6;a~S2|Gk@knrwY``oGHe zUq5epeEi=ZwONm^U%QpA+*iMHz2cmyH(m7^8YXj^IA}Doaj0HkSjZx!FvWqnMJS^o zfQeVYYXPGZHc4y_kC3cA^FTX1KPT_Y;@6Ycrd_kos^5OCYVWM9Q|6}C|9(B)d|2~Y zmwo)_)U(s;{x4mhe|Ft_Pct{(x@yCDKHFw3minpwc)!`Ni)XLhQn5eqzit%1b91Ks+PLUu z{+L=|VrcJM{i=K4ksWb=9~}RCSHHb4d-@xh>5nZ_TW~U42*H z>YdSC<(X;Xdl?w~)JRVW73;U&ntY+I_ImC|J=_QHw|@G4p>{{*+uvDl zUms+yzMFk@zqH-D``Mw*$G={yi^!Ure9Y?jf648CEdLe$cr^Rq@#x9bU(X*F*S7@dLmRd}hiXL199E~_KkE3A*ZcY2^R=sLzg|BrZqI8Mm;JW+Z1&bOJ5FzjzhC*@ z(<1xp%hiYP&(kgT*^x2N5t4d?_aAx4ti~Yxkq28gTe10%%EPEvoBw?@n$KbLZ;8x- z{Wi_8YzYq|Y>bAh*N^)Zq^JMeQk6URSHo5DtJ%M|^sj$az4!WG#cV07WvldmHJE;V z#lCjyHm2u$zgN_MKR6+I$Gzop#m}Dq&8RE?@?QTY(NT}-f`*5`Zg9SPb7p?n`nAFG z8-6XS%ANo1V`%OA@4M_%e4USne07!Iuq^KHg00uu=9Gv2fAsO+JN@OgHQ8(L-m8sp zmYZL3?|I=~V0!G^*RMEny)-K^Wf7(`Mny(LVCaW{&>!YUz4lHWGV!-Tp7C_`b6Mw< G&;$Sm(@gdN literal 2251 zcmeAS@N?(olHy`uVBq!ia0y~yV9a1(U{>H@1B&n!{`d=|Sc;uILpXq-h9ji|$Y(4L za(7}_cTVOd0|Q5?r;B4q#hkZy_xq)UG93R{tX2}e;_RgxH{Y)7CqeCS>or&gNGb<8||e^^R>;`CD_p$=;9pyYkw%rwt#i+kao4UVGfW+V1zB zYu}3Re{`;|Q$G7KTl4AWea+MV8n(3qaOzktG~|v`sriF>X}#iPu&jk%3Xha)~7|je)soSS;{A` z`|;T{{#wn;>+!#Sg}wQ>_tdQg2j9!}EWQ|4e3R|p$sgOdR{h*E#po2j!M~cb(@o=l zeVx8`bW&aak~buQ;{W2GyD&An_0%bIceG_ed|i$nVpNyU$2S$yfnW4(Mi~ckD+1b^TKW}{2tkV4MQN)p-8C$N#Y-_AHIzTefqTHh;s?(wTZbuYG_Q%s)w_(RD?EP$Gwe2?eF z+y@?PiGIaK`(N+>Mn7%4llFXW%Yjp$QB}QCv+q`ZwAnPTdj9rnr^4&^TK(Sm`JuR^}Re!i-i+}w5|7Dk+&nUQV(+tUC$Lcx{|2@s1{1Meaq`X!-Isa>% zXK9fAyU)|!9*v&K`0t@4JQqSk2`i<%%31x!taq|ggUr^|9teBBcH7z4b@kb$%dc&_ zo$4EVd`sB#t%qlMX&&!hf4c4-xNL~+d-!qRyxSJRszbvqN90H%m$6j{haqx>LM&HELL?+138b-#!W58B|8@U)``*sH{oc2`-@A4k z>8`J9tP22uzNg2*6951@3jpdnv^7;Hn=sOO)uMrQ^YqnL8BROo2h|>gJ>l*Kl(vH> z06@3e^WXvBgdE;rMY=BwLKiBxN2eKm)9eDey4OgoZgD}O;h4*@bk*I~QSRY;ATCtr z?49$U?C_eh8P(A~V*1FY!L`wFxBinYklu{TH-AzKX{(KQruEL@WLSPJwibPOU%c2C zq)PfftsyWQ*4UbBg6~pGOnQD=nb0JaQ)XuhI7uIP*c57 z)+zKs({7JSD@ZOm=M^Y)vpl*X1J zxSIavBX>Y;E%d)A39x;5mrO)ThOX3 zv7A3@J##ze<_rX9Et{OoL~m@xJN8G2nNnZRYrH8n17Wp0Bmp}IlX4)vtHSHDn z!{uEa;9_G~@;s%gqMA*G+Y?1z_HAL*0=uC`XE-ZhX=}I^+F!ivjYy26krt{%m-Jbd*>`qWxH0Nt%?KA@wQ9Wa)J89D9=efO9{GBSGZA5PQzO__7@%f~BWJ z7RY_vCIkU~F}WAxqnMO%u3#0Sf-ro@Kp6qzmw=F+27jPgRCu3s?v?3PMcm*rB?h5h z*-;~9Q;wfEq%Jo4JKXH>09F?^8nk%#_WY z$U~%!o{-E|GoD;ZpB~M>JEF&5s^>j98G!?}onx6}{I(+Zg}JB{^a$jQCP2CFU}a1%(Rx&tpz&wn^rmI< zKiNUGI3~Ox`28(xyPH*BND#NO zy9Yak<>zoMpv@6I@f~q)piE-?8voC~NnOg+8h&|W-_qw-mg)sVzIrz<(U6{&r-WmB zS#)#2z4h(}SgVlNO-^Fo>lBtqu#{@8W=;{`7j>#x1l>)_$5%*V<745)CT1Am!~bWR z*e53Ww0f}dTe1pIAx6p#bsynW60`>3kD-qYEMve;v27Y>PS}i2NqBwkc?fy1)Ghe( E-#ZnJEC2ui literal 2832 zcmeH|X;70_6ox+**%WZl)Cz$()rwB5hzPPr0YN%~R0)fqQB-6PGy|f9fT%+Sfm(14 zVQCn#fe;1RO(H~ywgQ4e8c7I<5<&>egph?S4JuPQO;Ovw-amIaGxt3Ay=S@P=j&x? zU}^vWfT8zZj{^Xp5di>N>-2O~Cq@ZkxoXlzx_SHSsRl~##E+`I2J(QH8&K5=<^q60 zlefoi|KvhG>t1dFeJyQBbh*wwtMnK#z4gnic#TV8Rq17qK+hV^wPt&OP6S>HGN?(bJof8xR}=A@bKyx%{64 z6^dzkm_tZOePsd-=)I1k66#q4(%NpxKtD8umv(Dgny6b&nDpcj(`w|IeNu`ir<{zo zAxu}|x8@k>hEiE}L(`|4bPf_`OlJoa;!#B(#nRaoaxX}z1BmU<>F9m^3@Ft$U01M6 z>w-x{x}T=u%I``v-1K+s+N92??m3p8nC+;s@rxZfNmFex&B6C=gvIzMxzFpfFd^Bh zh>}98htdv3i=-Ff#(Qh5BgO8aMV)n+^RCmB%j=I!Qjz3PSdI2E{t?O)Pa{&+g8m@I zl8A4OK92cQsc>hg^Qt%t+UuWs9O#EC=vK0`@AxaT?M{kcFqUJ?_=DMuq0OcO9<62WNnk3amt8 z;WcsoQ&m*M|Dw!BpDk(=P;ICqSI`mV!pi#Jghp&6vI-=hccb~1T>1R=iekr7aq1uXq2WWnh*{sLV4E2dE|BpH>TNPbd~b=yG5#-E>oNBwH*uQmiSmT zNa{nvmb?!LLSEBtxt6+wePqhqQmoU-kU&(KZBC*yww8RXh1| z9+=|<(!!Ivl-Ag!PpJ>{_b)tjFn=S&bdE71aZS*2B8`oRHRZm-S@{%-2*LN#slz0) z^o=l^h7+8!gHq!C_?&LZ6C!&>^nE;yG&UyI2}m)lsCDX{n;Py)FuZ-MDRYl78!-!? z$mvv0j7usAy=+eor$GlHf-_CgGQam(Mu$_KT!DhFm{so;m+#U!9O2g&a{0*zqB@0X+8Ow`=T51_x+0ppN zd`Hbqw1{IDCBgw(cUZ3(FgUF0f-l;?-|}Fbz8ab8q8Wbx99(j+P_5XR+>{`Hia1>P zW?SOL9hcxTwMY?w9W Qi}_>kJ-!}QZbviz1RV{X`2YX_ diff --git a/mobile/test/goldens/now/s5-min-130.png b/mobile/test/goldens/now/s5-min-130.png index 71bb012766af398933f13e776befa190547e5908..45a223bce98d808ec22261d298b8d9a4d522a946 100644 GIT binary patch literal 3403 zcmeH}YgAKL7RPUhib~C>%T+WySDo?IQDAJZQ}MWPl1V?#m+1eHf5Bm|M05JJd=k+wk{3-#;VFXx_|n{|Hw zz4!U=^UWvz`&KMRE(ZW$g|E-v&j0`x2>@`%rAth|AQC1FreP7@%lF_?(?ec*BHJ{F z;Xm8w1yl)ahXCL`$ak;j!PFvE50@%Tvgbe7M#RMkPp^Ey_IT35$(e#}2E#pgR|@-k4;}LS^B4T zpmY(^@q!2Z>(!ANpIEF|x&I2xYo3d90rgdWcJXPd5C{C4!K*A>B_w`mJLnEV{IhTp zE+uprhoBJ#G*B&dpWuX1w+*q--QLJK7h!hBBiC-W#w3$>)|C0}t6%nkSG-bT8 ze`JPXR$;u)&AAG%8q^%LrgM8j(5yNw?W-L_$CH_&AYx2mUmlYsZ~LIfo}@&Lk?3?9 zg`D^VuCfM!`KJ2q?5|hQR=@gDQ=plO%D%i8r_tcX;UVVzJtXA~d8E1`LRFweyi}4w z5nn$%(fND;Ya(s2i@@m(+&lkGkJdgTeEX{kD6$;7Lc(9N7`ZDx?bF4-vu=M=jL=jM zYRxTE0l1Fra_Yn;eNV#_?k>_-$!-mD;Ilb+YJc3P>62~I-O%2+H zd;g?L9-5a{qUj4hjnah$A?5LA%l9L>FqF$&kwkhG~gRp%J1? zgsbp$`VMumeX{;LT79o&?rkin4xl|6?umx(UU?zR-d;O-C%myiTbAzU{-{n$683F= zo`A}7DjTb_ZN;s&n70*f^|PxX4o()D+KvYWiKPzPS~P4P^k4Uv2(UWT`r$d#?D|jW*qds!78*e~b^#h66FN@^M+!O;NRfF&0dUF+A#*%Ijamh$pjA3>JHR zoVrK0tu=(}@^m){MnxbKXvm3Fq^{03^?L4(#}QRfvX(gT8zPV(tioi_$rAV#h^o{>49$*r)W9U3{$kAXv<O*!w7Wv7IpI3 zs6H}~GLTiQ=GvfMuvCj3&;72O#9Y?7kY+Pp^~_->kaW#sxksRXx0f=)t7MpUZp^SM zS(e}=xU-U;HwHPR^~gVBLDOPcmwTpQ_g-=OFNpY66NO(dsD(#I_1C;CeU`{+>l8}~ zn?hE#cv^TUU~UZ5VT(9hPmkDLrIr(5CI8nk=H7#r_z${(L#oQTT&#!;>F3Uc!yjshNma!zoB}zq3Gf z3c@6QQF%KUOuA5Q934IaQd4djJeO(iwtMLRwh8D<&4IsjcQlLq+h2Wt@@ zc#~VS<6*Ym%&#y2KngXPd8>4u^j1`g3(LbOV{P{4pe<@PHQa^Gt1*_>Q>?SpBH(h% xY(%44*C7+<)G^?|tuk=bU$b z=l<@6&x3c{yt(R42n1pi@Y$y!5QxQb2xQ?ptHtI!wkIcy=F1|ie?XX(d62A*o;R;8 zupztsA(b6#1|bkTV!)@{!&359ecR7R^2n_t(|DKVw=T=?w?w~h(Xj5zmeN4_u(Jj6 z5_P=|>X?Idkel2Uhmafdd3cQkyDJj9x5mBk0_1lp_u79sI{p16U2mF`0*bD*^N_TjdzPVS}vN z>);ma*Tg!*GDjWILo`UQR#aJm@)3kaabpKM5a}w8aK^Hkd?=4(O5K4*7Eg*God9=MkS%@P<2`(H<%5$-PX#>47lLM`uT#p__ z7@W>!EC*-m`swKH{=qxt7*6#Io|8Agr~#={lO5&q%)GqS;VVGf+h>Ce_NUZ=6?ECx zDyhXLqbq(t=%l*m46*AE@_*GL_)`uM1>;Y+3)sU<8dNS8dN9+~*TRpP@-S^b}Qc8Y+)vJ{7 zpQ4|6*q*btPk^B55A~cFstw{?0ss`Q-E+noNk^yvz!h+B@}3j<^}22*euS;Q^}RqA zO!QeERY9guM)Oe)gtGQfSw=pGK%KbXyaseW0xK#|%QbuG1hoEY{byYS* zS3_~3=o8PnjZ?niH4EpRM^EuKfEE*7$yvtMF_2-x$@5XJlAljk2}x(>=GrV`;$ET3 zhBWAvI@viQl8>@IDtH%`VgZ0c7Pe&Tq#5rz%uVT8_*J}Hr3-gOUM!;X>qIh{18Wd>v$ozF*U@(15-}E6sk=5s(gB-s z>dcRgge@Sd6rs-O>1Lf+xq5XzLcKN;3%?_xj z>0KCOpG8q+RD|DHSDHsdEJL^&a)|h9JJ&PY&q7wZ{tuXuraObpRL^2SeovuD?T+yC zI|ADvHG7!Y(6!iG496L}^to3SGfUs5(Nc@(rlGWbyygkDME6T$ZDzo10_t3kU22V}4R6<8DwU>Rl40vOr{^Q-b5(CRs_Y zt~Wl}Jz-ido<>W8=12nR*_y+8@8C00mMf&v7EBS^)=!H{4XH3CE87Hd@UjsXVmwKE zzeSv#UpLCGfrS^6QzcA1eX_izz}zGl|3%jWxzSttT>-aLg_LGe`K5P|ta4!KN_cTg z+_A@BTM{O!VJ2*ystu|G)Ge$y+(}8JIS+-8Cm-=j=%z7|0=WT=;Mfs4I_8?_rL@lS zT4-jz?xA9+T5M#GpgpO&*=Wip^?c<#BxRV13NL)2RmNP5-78!WYhEpG=nPz zv%%BScVo2NgzToj%Vvf3DCFGWHx6awPfm7mW$nno9JO7n)aPPKrY$ELnsCC@&)a}r zFjvtzbKTa-tPchYQW8}hZmiiqc$zMz0`*xTpBNtox0eh+$;yk@gqP*iCO9o4zwSy_ zvtX*CGnvh|`&tycX&;8pEep1xdMMDugtKV#9<8f(K}MDXo^7pu`kobTx-k4McKwB3m#+*FSDHAQD%9PW7f_{Pz|@`k`c9n#;kj+IAao=v)$L!*@}W|2Bqoh>;miGEAapI{1mudG--p( WZPi+NoKnxc5wJ7((@OtCXZ{1ACp)D8 diff --git a/mobile/test/goldens/now/s5-min-160.png b/mobile/test/goldens/now/s5-min-160.png index 44e8a0c0a055c5bc67ee3e3839cc9440e9083833..ca7cf40fe02d061b18d7c61f0492e73cd3de2232 100644 GIT binary patch literal 3866 zcmcgvdsI{R9lwYbY(=_ysx5@aIf|q-$pFye#Bcob9!R9>PW0aBox7#;};FqqnM zfNhG!8iT@)KmtjSMo8ciP?5O~!XqRQLs9_=kU+qYTu4l^ORF5fV~|IZ@vM(vpIEM51-a!5BUU}!$+2R>;?GR z1bf{35J2xjOu&!a^YJp~{UD76l$yUR+EHC>nOKUdT?0%mx*7VNB)}0^N@b*GkjgUHV zoFem(i8HZSXY9>Id~beyM7OY+855i*k}JICSyW172@-6$yN#Db^i~ z_?)BbEZ+CMZgObj?gO9XZZLP~25zjkvcGh|H2;Iw-FyLjkEq)uC7C$TvDY{MByDo= zLs7l2_-ce7I}U7ch9QBTYRV2uoY`6fd1k^3c%I!DjX!Lr7;4BoORi8vVQ|-}REWbW z($T0I{;+n)!?) zsc{P%CJGG49dh5(bu5j+a%W=Ch07bc z)Wn{LYIa(~R2r^{qvPJxVa9}ZORO*juzLtllC6#GlO|V)mn%x)>g7%FTY-O|OF`_2 zC|G4?EceCWGRYO2-1GFBhbhKhwV*h(KQ>oGlf1cL{uilL72Yt#Gh#*%XP_{esRz38 z3>OB({i;Ah=drApj1ojxyGJWqIx9vHj~RnO1@d&IxN%R%G+Kj%?&w7%Qqj|V#PlS&6rv1X{+e7r^(6T9cO6_KT9^q>Y-MP`Aip{j?Z+=^&aZHL%3eaL*{zIOlGijgzLpoYz*5$QuqHZ3h`*n7^lV$PG;!6+j_=2kqe z#9)=AcE{|#*UEnU`(m@4-CI2E2#UIz&y=h4>Js>$7ts?U zu~`N=8=+Ocw}`53JTQ!5ho=?9SSEu35p4WS?*JAvEU!~3gL$5m;s5mGuVwU3pF(B` z&X%2?z9g(@z-Lm09VK?gv?K+acCX$Ic%3|Meb}ryZTvgGpnbo;4e1!N{k2xnc*((4 z6<{Uy)?N$J@RML(9zBW?O8euHvT(JJTzxnNm#S#16@Ig8ECfYd7-@SzDMI!mzhcSS zqo8anLh=_GwdrFm3j9;4*oWS6QHvx!o^11r;SmwMVPH*)53IG7NMCXl;&1K#Q*98M zJ6+3l?@F~5O;3%Mt5q>@H6qqWs*^B!unrALx+3;9s0{;H8a06Iu7d>H^q-#yLb(H;>o*|r!e}aI+-9}$bq+xssDXZ z^L?C4=2o*c3-XmP9V1_jUVo=_wC2%jT3a%b<+?iA9nOs7<~2*SD**yIW^maHoRTU= z5Do{8RI1I15@f+7PYUW)h1C$yH^bbqX1`vk>{UPjz&;Ent6F~*?$vgTpG2{?)9TSB zjjq*U{eLAiq)^B@1npmair0$mSJ2>UI63i2<2jX@6b;cZ5H-zm-psa5Gy%!TNkuS!loY#gqj7S+%BZlz=A& z*Gec^ftX4fG{L2u87gy_NL$ej*hNa6$MF zjiX$CLZCk(pqv5ep*>qtr<(JAWl*SkKswf5?*g2g-TccO5w^gMc=Aj=4mH|ls-!f_ zPhLrDu-qx_lUF3FHKT+uk4P}ksCJYa91{X*9Hxr=aY$+Uo3gO1yI48mEO*+kt^anj zlbBPD1hZ9H3_N-xZ9jwRx>MS#otT`;djQR8tHMC!0!0uIs_e@~{>bt46ZKI)UF2|- zPbd|x@XuVmDjX!{S}{wHfQ9Q5j~hHilI_ptL_4v;yuvf1yzO roAccKb)VV3&}=LYajT4LH8q+J7BVfy0tm5-TRwjZ@S-1z{>y&>oHd_> literal 5651 zcmcgwX;f3!+K%EaS{1~$a#0d);o4Xgs9+g{07{hN0ChwHf)XSs5R7qv1PES5P=km(-q(~I7E*SGrF?my@3v)9^dKhOKT z?|I&R;YTlz#h)+#90GwX_WS|43j&!F3W4bTZT@HA7X#d+27JuJZ}QwT9~?3BgOkAd z9Q-biO^|{H(^nA4SAZvS|D}kzBu7-!x|Ap41zE zc}>|$bM14Yr-5)H!c^ND>&>39yrrThMM-L#5_|lfz;2v{CL7d-dwbz8&qXn_JD88o z?U05b6^Q))!lWgk?YGYFHG{-^!=FJmFIexkbME3FFT3e|x$@IYo6|Py7iT2qIzIiX zd#MrW74Y}O(J4Cv(l8VLa%x#)tT(cPeh&H}CBx>%&||L)bD6H(odvb!4h3~l+t|-k zWeoc^Ho*9-Ce1l^kiM;bcl8tNZBIW$#$GB9Miy(sGJXZ_g?TDMWL4q()z1R#KmU}t zW>e&}4EhWOqYkX{Ik63?{#o&pY4tGQL7fiO zc;C!RFZrK%T+pqOgHe5uX+pFxM7~zD5Um_Ira6=FFR&9Lo&F(9fab*{~Gdmj;q!yKrQXQf1 zlD_1|sxzOh+InNo#igO~x8^PX6Q-SogYm5|AFuad5V8tS%NDKR<48#MvoZGlQ=+Rh za$}b+qRe%lT0Q3-GklcQhJ$UASQRLp7QYFdUWp)1MVD%ml6)7z8{%HKW&;D(>g3f~ zxD(fIuRZaZN=z4Me-7(_r6pm{$RG6l5n`Sl^#4qg8L2PRzXP7PJ_X3ewEqI0SR7b- zv;V?g@pNUH8SxQT#?W8F6iiK%X@4gt$N?umOYiZol8`zy{WK1`0{vC_?OL7QNex}9 z)9aC_&wviIN#)qg82TDZ(8sU@W z?th0ndvzuQOs@{CY6}UC4%%Ce=d{&?u2D_dq9fHBSa{yhq5|De0ZYSIDfNGoh@t!` z-Nw#H-D-sg@*O8FaZ{*CITKdGs*GIny)D>ey zN~JyAjDlMoB#$drBAR$qD5cw;~-U+rE4^YX0YK0hchg$CSlxav1CA2r$q-{sgH#<8OE7u@GBDF`4 zWV$ITV55q_Gda$tPS)(}h=wk_d|+a5*1YyzwQn-5~yZ zn7Olg&I#%@Cb&c?oD?3(Ja0TDx!RYxfy4L7rLHi^H4l+`WD9IHBORkwc#D?_ZLINd zGQ-Hout$?i3ojYQy_56Uug0XJ<71dIe%1jpsdc!czGKulpj^YP69rU1VGSX+#K7@W zijAU**z&`sSp;ly)TvOJ4}sHQ=h}{z+`*nnvtwZ%93Wpee%TPf^H6Jim~he^;&p~- zB%gpFAc)y4FIKS|(Rb-5b1|0cn}zOd-tDS2O{St-xD3)?2gEO$@wUP}eIpvKA&C+- zITDr0046q^u9=29t=sWv7xP;+w%x;^(jr@i8)aG4m~UqD`P=Wml=R=ygGH+o1rfqy z{Pi`A&`{;@I==lKYIbGR!_>;Uq(`TRI*f25pskDJAHq)iKWil0eK(JL$l1iGQ3%_@ z#?R<*8534RKr}g~&>1jek$lQld*sww=bQcRS>oYz%D_@tQjvQp9E_eN=?f!kOkziV ze{a&@Ng4B*kdxhJLFlS%9owI2tDpcUX*IoA-idnBX@pgScoL(|SyYdj_qfIA)A)v? zqlT$mx&Mm9Ot>R=Wr!uoev2uUfHe@-g+?EjRhs3V%B$q)CIZN|fn(pcrVAWKTIkZ0_qFx5xu42V-K}z6Z^y?+mxwFLI8J8whky^_Ct3F_OdyLtzO}T6$ zY5tk>VSR@^f8X!o?XPrJr%^JQSRFzvNUf}&7H3fgYlVe@H^M$~G`QHAp*(YEw^Qp8 zERsz`geaIWXvlk|3G#S(?PmRvP9RbTddrHG(g&O4nq$37SD@;>TMzRbD7uzFZ`~*l zP(WhWs;$yE4jaIZ;?dzzmZwY8wU~7llE6%a6TiX9eAA6X$zyg-f0Q5ow*h{0$l`Md z`{E2&zf6Sj`GpvQbe4!`UiVut2ICKuE5tbit1{`ur*XmMH%rvP5%RUY(_PRbOul3X zMf;k=x1Q)Jf?BE{{_{7WWh1sRuoCX~R;X-Zx)Bd~&h7Mzlea%v;67*(uOu6^mk5}>0HV~A{A~Jw%(=D zP${CGfvsoQUs48b=@JmR75!(x7I*9GvYmfcV4I2F!4wG-?A_SbsAM0a)VKPiUn@`- zIvws|0Fxn3?Q!}5d;CMNY?Ysq^1*i3D)NYwSbNGnOj*}C;+uOKxAxUF?n3}4=ST%Z zfWAv|LSV+_+4xcE)*)<7SP|yiwg^D-Cm??M(S!@(EiK@eb!YpQ+BZ;@8b2`WK0dY| z?B)GEPcnc|Ny03#VgPeBRaC7!hL$2CmaT9k1At;tqYKO;atOfKC8sKPF{!;>e?==) zMR|xU9KnP)VPiQQhHA}e!8zz2u&7#Lj&W8Yx=h%7*~k!CeY@K@wNjSVsM9;)M>Mw~ zJtKODDTSQL04V}p54Z7C(BMHaF1K|W-&Sf$RLHlyiKUj}Fx2y$mg^BBs8QjMHxgf^ z={>&u|Ll(Mdb*AcU2=Clx|^e{+fAlvFe~S(Niq6wnwJ*wZh$7Q`Es`=YTm&ElLl`( z@%wzT!~IYrR;Xow7=UAh3_COJ0|nKtZ%Z2PhJyNaXAQdEhak;b9D!z@qruS*Z9yY( zq>K`{jhD5M5paKH?2h`WBEq3Z$3hxS!psH@c@>)H_j!yv zlQCO#({VK!-Hx0;eoba!@JD6q)4gUl=-G;qa$3ow5(V{;9d(7MG8Ir1`-b;9%jG?` zNL5ry#XSYJW3Ao5HYMRa9-Y!HQPTExEo2W!Yl8IudJ}XP zD}DJIyEwk;tyHIX+OG6m1>s-+$R@2?R#kTeMi84{l@Dfg(P)>08FmE7)L{mXU#bj5S2M(Sf5E!+!U3)$}pZ?eL{OvxtqU*3B> zKlqcq;b(5n3|FUzM{E>oEiT=g({8okajW40v%vc59QeanAMxAoeU76SS4z)%tf~0= zo%XYJ19#*a+k7A7Wv5*SHMSM7fZGWae)E%b*zzp3_uP8P_+jHsvMNTw3sMr99w2P_ zzWDq)VuJfG4>D^QQMe37?o@KnC54L1LvB!z$-RND0v$g`E$t{|DJ5|Oh6JKXv0uob zn6_S5maq+{e$zTrR@TYCQQ|XKc0|p4PeT-{bsFzhB#9QSg#chA3_N7x4sNntUR>?? zc{8iKV+AI6Y<{qquX~ipxmK%OV6sz90e|KZ7Jij%6y!G5pxa}%>kQ}Pke_BH_Ktp( znX_{sb+H;KnsDn5#tyOy!hh|jb?^`2xeco~kP!B@VS+FO2(XPnlW%aeLORV4Q0Jwu1g z%_!OqiR2W%CT;OZ7K6j4t@tvT(8s_sUFSbG zz354vg8HBwVWF=OMZ?r%;)mid?h&$NnG^jj3J%n1nJNM0b4BZwYv~GxTc`F9N%_KN zB}SheGisWkWda}!zBzDnfv`(}V8;T>Rd_eh1GZ1G)XtShVhI zvfOH;1YM=$58GWLLjDFLw-75vS6!WUEx4a0iX7LcLv>q3l;JG!Zq z#fNmoQ(v&y)(I5YxzJwE0l-!9L}&sw3f&jvIU2_Mb$8xbDmT}7TBARrAaFA5km`kn z3<)z2dumBr%fPD%#_S1wH&3xUYv~{%Ze?8aG2YSI)7)HC-qN$J>y3-z&{l20wtJtcH~u+0sw|i-@Bmo0;2rzbR>@I`|8W2Sm}1>@%1k(_J|7KD zKG8TBYA0vpElM&ag4^Wc*C>+-{AshMF0D|8!9Wo+GL|w(KwYwsN)4Jc-lOmLc4XX$x7m6|<<`j1r z<<~SBM)<)yvMrogO2Ez?(>?F((!P&GvtN4QOehNR9XMZjgYsRDDm~VJUXpCHv{8Kv zw$EWVt}+-qrl-wOLw5BE-GGAgq%Ld5w>N!{vpKiM>J4J?K&z7%i5XMH{=@C=wXymW z#0M9)$(omIR)H;cPPj@NvZ&J{1=fvsVG)!QGrcoMvcHfrRD0fpjsnc7%_a0 z{qTbJ-x|P6_8!annYl&<*kB(L%IfV1JE?svZ5uHR=rHjaa%f>eXEKb)lZYmRAMrXV zM4i?kuC9>Ee`K@wpB%ZNOHUgr9bZ?aBQtz8b4?A|+4H6Y{DIrfK(pMcftwY4O?W&t zZQ9nEcfx;~%cQ7n7Wu=`aWn-_4W;p=JV{MOj&Od2FLx!TLjG*__3=h>22;^ctL-sT zDTKb4iz(;f{nW@zNSz+L!9-c{?NY-buo{lKVsBStcIbdPRW|*2-``_|@zS~AfUr-h I_Qjw53ra3{GXMYp delta 1633 zcmYk5i(At79>=-(=%|#+meW9XTFaGdX6DMFP-&DcK8mo>u!}oS ze44U*XL@D5Ma?TpA45xQzHr$;PBspt?clw;N(bdHNra{hXFSha^N!-5>Ab61!!3-w zHB%ceA<`g+9njH#2d_#zpT8qowt~GXbN>?Q7U%Iev5krg4mM4cncP591j&;Q1))ol zN!08$*a7!Y1}0(fDfi~qvnA%jo*tm^g-fFnXPN1du|SzgGo)@bbo#oy9EDqafr;_B z4{nuob8l`xTXH*oqVO#yUKalbxl!5WbXXpVYT5fxY}VyX6exsR4? zO|{|Fmm~9nP|Ncx!x?d8ju}soyRY1z=T6etNpvTrnEsV8V|s3>IP#+LUh4ox|MCQ1 zj+(90ZedpTBTZBxcRk|HPa;64gg7c+idfVdPZ*o<|j~U9yT!r-O4jdU3JAMg30J%Gn+gGYt-Blu7pi4>q&snKmkB zTKIqvpVFnbYQKD35ndGn>YzLP@Bt^99P6msQ;(6(WmopPTN;dU+O;NZpQ)uj+WqP| zD8xSuUw&>NJFyI~?8dtC`0{4o#UZK@n`%cdE}vGLMcy`JZdTLx`t9G?|F(8y8|yA- zd%y>mOyEv<0*DD*N{=r{yW^xe7E02(^P7wL`8Ye8Gaq8rwHaZr_WU`?0$ymg&{e=8 ze00TgyYR-CXP{wTBG0qNJA01{uqPS_1Ru4$Txyi2W92}!hGn)xN8 z^uu$^U5bRf>tzc+YE(wQUAOGaYe-TV?SAhCRZpP$sggdrNF;k7^%+)|;yeC3_Wv-* zZ3*}>Jlr4V>ydu|t;=jnLR^?W*>k|tw@C)j9Q+!s?spRUpMlP#G;OQ(7Fe<|LPn5i za%cwOwoFXfk3exECG+ zvICYKTQ3_*oso|Vai2hu6nU*KbXFx-v1%kvcUu+>Us>5{1**MznbgKn_T%V>EXqd1 zJ~0qFBI8ERaj)GC>l!6zwyQfdK^QS1sq&UjZ+vdJ8|6Bf?9V$E77$U#TOG^z_StF? zqp7^WGT}XbF875?;rUiGZWu6Ji%cgRtBF4#`25)1(qac`IvD-=HBi-F%SkT)bC{!vp`>kZ@D~aSivyp@537O&&Guwf5lQ;yE9rOkkm+y zCbe?;pWDcn3F4VN-_IJUt?LZ+9u&mG*9M};Iw>9%D0uC_0p7Er)UJNsTr>AQngd#` zvb2*t6FzOV>P)-#f2R)GnZJlEHxd--gfyh@BZplxA?zuw_&&E+!!~_b@4RavHd#sh zhpR;p2rkvnzP64Rde~0Rjmp%HQvOBw4(DNrEDl8{ZQ5rQ72Z%-N4i_8B(s`w6^hK| zaxVA)N0n34uNL;$>?ld=Tx~nO7WCEsk=Lpb3XkQK0IDY