- 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
452 lines
14 KiB
Dart
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é.',
|
|
};
|
|
}
|