From a7e10eba7a300c03e5f2aeae4c42ec795613ca3d Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 13 Jul 2026 00:48:29 +0200 Subject: [PATCH] feat(legal): in-app Privacy & rules screen with plain-words summaries and links to the full documents --- apps/app_seeds/lib/app.dart | 5 + apps/app_seeds/lib/ui/about_screen.dart | 8 ++ apps/app_seeds/lib/ui/legal_screen.dart | 111 ++++++++++++++++++ apps/app_seeds/lib/ui/settings_screen.dart | 6 + apps/app_seeds/test/ui/legal_screen_test.dart | 50 ++++++++ 5 files changed, 180 insertions(+) create mode 100644 apps/app_seeds/lib/ui/legal_screen.dart create mode 100644 apps/app_seeds/test/ui/legal_screen_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 213286f..a9d40d5 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -33,6 +33,7 @@ import 'ui/favorites_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/legal_screen.dart'; import 'ui/plantares_screen.dart'; import 'ui/sales_screen.dart'; import 'ui/market_offer_detail_screen.dart'; @@ -254,6 +255,10 @@ class TaneApp extends StatelessWidget { path: '/about', builder: (context, state) => const AboutScreen(), ), + GoRoute( + path: '/legal', + builder: (context, state) => const LegalScreen(), + ), GoRoute( path: '/inventory', builder: (context, state) => BlocProvider( diff --git a/apps/app_seeds/lib/ui/about_screen.dart b/apps/app_seeds/lib/ui/about_screen.dart index 506417d..0f1b8ba 100644 --- a/apps/app_seeds/lib/ui/about_screen.dart +++ b/apps/app_seeds/lib/ui/about_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -68,6 +69,13 @@ class AboutScreen extends StatelessWidget { mode: LaunchMode.externalApplication, ), ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined, color: seedGreen), + title: Text(t.legal.title), + subtitle: Text(t.legal.subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/legal'), + ), ListTile( leading: const Icon(Icons.description_outlined, color: seedGreen), title: Text(t.about.openSourceLicenses), diff --git a/apps/app_seeds/lib/ui/legal_screen.dart b/apps/app_seeds/lib/ui/legal_screen.dart new file mode 100644 index 0000000..5915c01 --- /dev/null +++ b/apps/app_seeds/lib/ui/legal_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// Where the full legal documents are published. The masters live in the +/// repository under `docs/legal/` and must stay in sync with these pages. +const String legalBaseUrl = 'https://tane.comunes.org/legal'; + +/// "Privacy & rules": in-app, human-language summaries of the privacy policy, +/// the terms + community rules, and the seed-legality notice, each linking to +/// the full published text. +class LegalScreen extends StatelessWidget { + const LegalScreen({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.legal.title)), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + _Section( + icon: Icons.lock_outline, + title: t.legal.privacyTitle, + body: t.legal.privacyBody, + url: '$legalBaseUrl/privacy', + ), + _Section( + icon: Icons.handshake_outlined, + title: t.legal.rulesTitle, + body: t.legal.rulesBody, + url: '$legalBaseUrl/rules', + ), + _Section( + icon: Icons.grass_outlined, + title: t.legal.seedsTitle, + body: t.legal.seedsBody, + url: '$legalBaseUrl/seeds', + ), + ], + ), + ); + } +} + +/// One titled summary block with its "read the full text" link. +class _Section extends StatelessWidget { + const _Section({ + required this.icon, + required this.title, + required this.body, + required this.url, + }); + + final IconData icon; + final String title; + final String body; + final String url; + + @override + Widget build(BuildContext context) { + final t = context.t; + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: seedGreen), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: seedOnSurface, + height: 1.5, + ), + ), + TextButton.icon( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: seedGreen, + ), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(t.legal.readFull), + onPressed: () => launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index aeaadcd..b00b5ae 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -67,6 +67,12 @@ class SettingsScreen extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => context.push('/about'), ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined, color: seedGreen), + title: Text(t.legal.title), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/legal'), + ), ], ), ); diff --git a/apps/app_seeds/test/ui/legal_screen_test.dart b/apps/app_seeds/test/ui/legal_screen_test.dart new file mode 100644 index 0000000..7ffa5d9 --- /dev/null +++ b/apps/app_seeds/test/ui/legal_screen_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/legal_screen.dart'; + +void main() { + Widget app() => TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: LegalScreen(), + ), + ); + + testWidgets('shows the three summaries with their full-text links', ( + tester, + ) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget(app()); + await tester.pump(); + + expect(find.text('Your privacy'), findsOneWidget); + // No account, no tracker, plain-words copy is on screen. + expect(find.textContaining('without an account'), findsOneWidget); + expect(find.text('Read the full documents online'), findsWidgets); + + // The later sections start below the fold of the test viewport. + await tester.scrollUntilVisible(find.text('About sharing seeds'), 300); + expect(find.text('Rules of the road'), findsOneWidget); + expect(find.text('About sharing seeds'), findsOneWidget); + }); + + testWidgets('renders under RTL without overflowing', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.en); + await tester.pumpWidget( + TranslationProvider( + child: const MaterialApp( + localizationsDelegates: GlobalMaterialLocalizations.delegates, + home: Directionality( + textDirection: TextDirection.rtl, + child: LegalScreen(), + ), + ), + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.byType(LegalScreen), findsOneWidget); + }); +}