tane/apps/app_seeds/lib/ui/intro_screen.dart
vjrj 064248144a refactor(branding): rename app Tanemaki → Tane
Public name is now Tane (種, "seed"); Tanemaki (種まき, "to scatter seeds")
is kept only as etymology in the About screen and onboarding.

App not yet distributed, so format tokens rename cleanly with no migration:
backup extension .tanemaki→.tane, QR scheme tanemaki://→tane://, file
prefixes tane-*. Website link and species-catalog User-Agent →
tane.comunes.org. Android label capitalised to "Tane".

Identity/crypto constants already use org.comunes.tane (derivation, HKDF
backup, sync namespace, TANE1 recovery tag) — left untouched, so keys,
backups and sync are unaffected. Tests updated; analyze + full suite green.
2026-07-12 13:04:16 +02:00

228 lines
6.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import 'seed_glyph.dart';
import 'theme.dart';
/// One onboarding page: a large emblem (a bundled seed glyph or a Material
/// symbol), a title and a short body. Illustrations use the app's own icon set
/// rather than photos — no image licensing, and it keeps the bundle light.
class _Slide {
const _Slide({
this.glyph,
this.icon,
required this.title,
required this.body,
});
/// A `seedks` glyph char (see [SeedGlyphs]); mutually exclusive with [icon].
final String? glyph;
/// A Material symbol, used where no seed glyph fits (privacy, sprout).
final IconData? icon;
final String title;
final String body;
}
/// The first-run intro carousel (also reachable later from the drawer): a few
/// swipeable cards explaining what Tane is, how privacy works, how sharing
/// works and the Plantare. [onDone] fires when the user skips or finishes, and
/// is where the caller marks the intro as seen and leaves the screen.
class IntroScreen extends StatefulWidget {
const IntroScreen({required this.onDone, super.key});
final VoidCallback onDone;
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<_Slide> _slides(Translations t) => [
_Slide(
glyph: SeedGlyphs.scattered,
title: t.intro.slides.welcome.title,
body: t.intro.slides.welcome.body,
),
_Slide(
glyph: SeedGlyphs.jars,
title: t.intro.slides.inventory.title,
body: t.intro.slides.inventory.body,
),
_Slide(
icon: Icons.lock_outline,
title: t.intro.slides.privacy.title,
body: t.intro.slides.privacy.body,
),
_Slide(
glyph: SeedGlyphs.share,
title: t.intro.slides.share.title,
body: t.intro.slides.share.body,
),
_Slide(
icon: Symbols.potted_plant,
title: t.intro.slides.plantare.title,
body: t.intro.slides.plantare.body,
),
];
void _next(int count) {
if (_page >= count - 1) {
widget.onDone();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final slides = _slides(t);
final isLast = _page == slides.length - 1;
return Scaffold(
backgroundColor: seedCanvas,
body: SafeArea(
child: Column(
children: [
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: widget.onDone,
child: Text(t.intro.skip),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
itemCount: slides.length,
itemBuilder: (context, i) => _SlideView(slide: slides[i]),
),
),
_Dots(count: slides.length, active: _page),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => _next(slides.length),
child: Text(isLast ? t.intro.start : t.intro.next),
),
),
),
],
),
),
);
}
}
/// The centred emblem + title + body for a single [slide].
class _SlideView extends StatelessWidget {
const _SlideView({required this.slide});
final _Slide slide;
@override
Widget build(BuildContext context) {
// Centre when there's room, scroll when the viewport is short (small phones
// in landscape, split-screen) so the card never overflows.
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 160,
height: 160,
decoration: const BoxDecoration(
color: seedPrimaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: slide.glyph != null
? SeedGlyph(slide.glyph!, size: 76, color: seedGreen)
: Icon(slide.icon, size: 72, color: seedGreen),
),
const SizedBox(height: 36),
Text(
slide.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedTitle,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 14),
Text(
slide.body,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedOnSurfaceVariant,
fontSize: 16,
height: 1.45,
),
),
],
),
),
),
),
),
),
);
}
}
/// The page-position dots: the active one is a wider green pill.
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: i == active ? 22 : 8,
height: 8,
decoration: BoxDecoration(
color: i == active ? seedGreen : seedOutline,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}