Files
GameTime/lib/presentation/share_inbox_screen.dart
Blomios b12a04ba30 feat(monetization-261): prepare entitlement infrastructure and quota enforcement for release
- Add EntitlementSnapshot, EntitlementRevalidationUseCase, and BillingUseCases
- Add DriftEntitlementSnapshotRepository for offline entitlement caching
- Add HealthConnect gateway and integration
- Add quota enforcement in share acceptance (server and UI)
- Add entitlement API endpoints in server
- Add privacy policy v1 and release compliance checklist
- Add QA gates and device runbooks for release validation
- Add demo content screen and settings screen

Ticket #261
2026-08-28 23:12:25 +02:00

452 lines
14 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'monetization_ui.dart';
import 'theme.dart';
final class ShareInboxScreen extends StatefulWidget {
const ShareInboxScreen({required this.shareUseCases, super.key});
final ShareUseCases shareUseCases;
@override
State<ShareInboxScreen> createState() => _ShareInboxScreenState();
}
final class _ShareInboxScreenState extends State<ShareInboxScreen> {
late Future<List<ShareInboxItem>> _items;
@override
void initState() {
super.initState();
_items = widget.shareUseCases.refreshInbox();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Partages reçus')),
body: FutureBuilder<List<ShareInboxItem>>(
future: _items,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final items = snapshot.data ?? const <ShareInboxItem>[];
if (items.isEmpty) {
return const _EmptyInbox();
}
return RefreshIndicator(
onRefresh: _reload,
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final item = items[index];
return _ShareInboxTile(
item: item,
onOpen: item.resourceType == ShareResourceType.pack
? () => _openPack(item)
: null,
onAccept: item.status == ShareInboxStatus.pending
? () => _accept(item)
: null,
onDecline: item.status == ShareInboxStatus.pending
? () => _decline(item)
: null,
);
},
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemCount: items.length,
),
);
},
),
);
}
Future<void> _reload() async {
setState(() {
_items = widget.shareUseCases.refreshInbox();
});
await _items;
}
Future<void> _accept(ShareInboxItem item) async {
await widget.shareUseCases.acceptShare(item.shareId);
if (!mounted) return;
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_acceptedMessage(item.resourceType))),
);
}
Future<void> _openPack(ShareInboxItem item) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => _SharePackDetailScreen(
item: item,
onImport: item.status == ShareInboxStatus.pending
? () => _accept(item)
: null,
onDecline: item.status == ShareInboxStatus.pending
? () => _decline(item)
: null,
),
),
);
}
Future<void> _decline(ShareInboxItem item) async {
await widget.shareUseCases.declineShare(item.shareId);
if (!mounted) return;
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Partage refusé.')));
}
}
final class _ShareInboxTile extends StatelessWidget {
const _ShareInboxTile({
required this.item,
required this.onOpen,
required this.onAccept,
required this.onDecline,
});
final ShareInboxItem item;
final VoidCallback? onOpen;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final summary = _summaryFromPayload(item);
return CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_resourceIcon(item.resourceType)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_resourceTitle(item.resourceType),
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Text(
summary.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text('Envoyé par ${item.senderUserId}'),
if (summary.detail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(summary.detail),
],
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Chip(
visualDensity: VisualDensity.compact,
label: Text(_statusLabel(item.status)),
),
if (item.resourceType == ShareResourceType.pack) ...[
const SizedBox(height: 4),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_packBadge(summary.itemCount)),
),
],
],
),
],
),
if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16),
if (item.resourceType == ShareResourceType.pack)
FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
else
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
child: const Text('Refuser'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: onAccept,
child: const Text('Accepter'),
),
),
],
),
],
],
),
);
}
}
final class _EmptyInbox extends StatelessWidget {
const _EmptyInbox();
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Aucun partage reçu', textAlign: TextAlign.center),
SizedBox(height: 8),
Text(
"Les programmes et séances qu'on t'envoie apparaîtront ici.",
textAlign: TextAlign.center,
),
],
),
),
);
}
}
final class _SharePackDetailScreen extends StatelessWidget {
const _SharePackDetailScreen({
required this.item,
required this.onImport,
required this.onDecline,
});
final ShareInboxItem item;
final VoidCallback? onImport;
final VoidCallback? onDecline;
@override
Widget build(BuildContext context) {
final summary = _summaryFromPayload(item);
final workouts = _packWorkoutNames(item);
return Scaffold(
appBar: AppBar(title: const Text('Pack reçu')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
CourtBlazerAccentPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
summary.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text('Envoyé par ${item.senderUserId}'),
const SizedBox(height: 8),
Chip(
visualDensity: VisualDensity.compact,
label: Text(_packBadge(summary.itemCount)),
),
const SizedBox(height: 16),
Text('Séances', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final name in workouts)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_note),
title: Text(name),
),
if (item.status == ShareInboxStatus.pending) ...[
const SizedBox(height: 16),
QuotaImpactNotice(
acceptedItemCount: _quotaImpactCount(item, summary),
acceptedItemLabel: _quotaImpactLabel(item.resourceType),
),
const SizedBox(height: 16),
FilledButton(
onPressed: onImport == null
? null
: () {
onImport!();
Navigator.of(context).pop();
},
child: const Text('Importer'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: onDecline == null
? null
: () {
onDecline!();
Navigator.of(context).pop();
},
child: const Text('Refuser'),
),
],
],
),
),
],
),
);
}
}
final class _InboxSummary {
const _InboxSummary({
required this.name,
required this.detail,
this.itemCount = 0,
});
final String name;
final String detail;
final int itemCount;
}
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
try {
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
final name = payload['name'] as String? ?? 'Partage GameTime';
return switch (item.resourceType) {
ShareResourceType.program => _InboxSummary(
name: name,
detail: _programDetail(payload),
),
ShareResourceType.workoutTemplate => _InboxSummary(
name: name,
detail: _templateDetail(payload),
),
ShareResourceType.pack => _InboxSummary(
name: name,
detail: _packDetail(payload),
itemCount: _packItemCount(payload),
),
};
} on Object {
return const _InboxSummary(name: 'Partage GameTime', detail: '');
}
}
String _programDetail(Map<String, dynamic> payload) {
final exercises = payload['exercises'] as List<dynamic>? ?? const [];
final sets = exercises.fold<int>(0, (total, raw) {
final exercise = raw as Map<String, dynamic>;
return total + (exercise['setsCount'] as int? ?? 0);
});
return '${exercises.length} exercice${exercises.length > 1 ? 's' : ''} · '
'$sets série${sets > 1 ? 's' : ''}';
}
String _templateDetail(Map<String, dynamic> payload) {
final programs = payload['programs'] as List<dynamic>? ?? const [];
var exerciseCount = 0;
for (final raw in programs) {
final program = raw as Map<String, dynamic>;
final snapshotJson = program['programSnapshotJson'] as String?;
if (snapshotJson == null) continue;
final snapshot = jsonDecode(snapshotJson) as Map<String, dynamic>;
exerciseCount +=
(snapshot['exercises'] as List<dynamic>? ?? const []).length;
}
return '${programs.length} programme${programs.length > 1 ? 's' : ''} · '
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
}
String _packDetail(Map<String, dynamic> payload) {
final count = _packItemCount(payload);
return '$count séance${count > 1 ? 's' : ''}';
}
int _packItemCount(Map<String, dynamic> payload) {
return (payload['workouts'] as List<dynamic>? ?? const []).length;
}
List<String> _packWorkoutNames(ShareInboxItem item) {
try {
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
final workouts = payload['workouts'] as List<dynamic>? ?? const [];
return workouts
.whereType<Map>()
.map((raw) => raw['name'] as String? ?? 'Séance partagée')
.toList(growable: false);
} on Object {
return const [];
}
}
String _packBadge(int count) {
return 'Pack · $count séance${count > 1 ? 's' : ''}';
}
int _quotaImpactCount(ShareInboxItem item, _InboxSummary summary) {
return switch (item.resourceType) {
ShareResourceType.program => 1,
ShareResourceType.workoutTemplate => 1,
ShareResourceType.pack => summary.itemCount,
};
}
String _quotaImpactLabel(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'programme',
ShareResourceType.workoutTemplate => 'séance',
ShareResourceType.pack => 'séance',
};
}
IconData _resourceIcon(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => Icons.list_alt,
ShareResourceType.workoutTemplate => Icons.event_note,
ShareResourceType.pack => Icons.inventory_2_outlined,
};
}
String _resourceTitle(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme',
ShareResourceType.workoutTemplate => 'Séance',
ShareResourceType.pack => 'Pack',
};
}
String _statusLabel(ShareInboxStatus status) {
return switch (status) {
ShareInboxStatus.pending => 'À traiter',
ShareInboxStatus.accepted => 'Accepté',
ShareInboxStatus.declined => 'Refusé',
ShareInboxStatus.revoked => 'Retiré',
};
}
String _acceptedMessage(ShareResourceType type) {
return switch (type) {
ShareResourceType.program => 'Programme ajouté.',
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
ShareResourceType.pack => 'Pack importé.',
};
}