299 lines
9.7 KiB
Dart
299 lines
9.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import '../../../shared/theme/theme_extensions.dart';
|
||
import 'package:flutter_animate/flutter_animate.dart';
|
||
|
||
class OnboardingSheet extends StatefulWidget {
|
||
const OnboardingSheet({super.key});
|
||
|
||
@override
|
||
State<OnboardingSheet> createState() => _OnboardingSheetState();
|
||
}
|
||
|
||
class _OnboardingSheetState extends State<OnboardingSheet> {
|
||
final PageController _controller = PageController();
|
||
int _index = 0;
|
||
|
||
final List<_OnboardingPage> _pages = const [
|
||
_OnboardingPage(
|
||
title: 'Start a conversation',
|
||
subtitle:
|
||
'Choose a model, then type below to begin. Tap New Chat anytime.',
|
||
icon: CupertinoIcons.chat_bubble_2,
|
||
bullets: [
|
||
'Tap the model name in the top bar to switch models',
|
||
'Use New Chat to reset context',
|
||
],
|
||
),
|
||
_OnboardingPage(
|
||
title: 'Attach context',
|
||
subtitle: 'Ground responses by adding files or images.',
|
||
icon: CupertinoIcons.doc_on_doc,
|
||
bullets: ['Files: PDFs, docs, datasets', 'Images: photos or screenshots'],
|
||
),
|
||
_OnboardingPage(
|
||
title: 'Speak naturally',
|
||
subtitle: 'Tap the mic to dictate with live waveform feedback.',
|
||
icon: CupertinoIcons.mic_fill,
|
||
bullets: [
|
||
'Stop anytime; partial text is preserved',
|
||
'Great for quick notes or long prompts',
|
||
],
|
||
),
|
||
_OnboardingPage(
|
||
title: 'Quick actions',
|
||
subtitle:
|
||
'Long‑press the top‑left menu to open shortcuts like New Chat, Files, and Profile.',
|
||
icon: CupertinoIcons.line_horizontal_3,
|
||
bullets: [
|
||
'Tap to open chats list; long‑press for Quick Actions',
|
||
'Jump instantly to New Chat, Files, or Profile',
|
||
],
|
||
),
|
||
];
|
||
|
||
void _next() {
|
||
if (_index < _pages.length - 1) {
|
||
_controller.nextPage(
|
||
duration: AnimationDuration.fast,
|
||
curve: AnimationCurves.easeInOut,
|
||
);
|
||
} else {
|
||
Navigator.pop(context);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final height = MediaQuery.of(context).size.height;
|
||
final isSmall = height < 720;
|
||
return Container(
|
||
height: height * 0.7,
|
||
decoration: BoxDecoration(
|
||
color: context.conduitTheme.surfaceBackground,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(AppBorderRadius.modal),
|
||
),
|
||
boxShadow: ConduitShadows.modal,
|
||
),
|
||
child: SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Spacing.lg),
|
||
child: Column(
|
||
children: [
|
||
// Handle bar
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
margin: const EdgeInsets.only(bottom: Spacing.md),
|
||
decoration: BoxDecoration(
|
||
color: context.conduitTheme.dividerColor,
|
||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||
),
|
||
),
|
||
|
||
Expanded(
|
||
child: PageView.builder(
|
||
controller: _controller,
|
||
itemCount: _pages.length,
|
||
onPageChanged: (i) => setState(() => _index = i),
|
||
itemBuilder: (context, i) {
|
||
final page = _pages[i];
|
||
final content = _IllustratedPage(page: page);
|
||
return isSmall
|
||
? SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Spacing.lg,
|
||
),
|
||
child: content,
|
||
)
|
||
: content;
|
||
},
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: Spacing.md),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: List.generate(_pages.length, (i) {
|
||
final active = i == _index;
|
||
return AnimatedContainer(
|
||
duration: AnimationDuration.fast,
|
||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
height: 6,
|
||
width: active ? 20 : 6,
|
||
decoration: BoxDecoration(
|
||
color: active
|
||
? context.conduitTheme.buttonPrimary
|
||
: context.conduitTheme.dividerColor,
|
||
borderRadius: BorderRadius.circular(
|
||
AppBorderRadius.badge,
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
|
||
const SizedBox(height: Spacing.lg),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(
|
||
'Skip',
|
||
style: TextStyle(
|
||
color: context.conduitTheme.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
onPressed: _next,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: context.conduitTheme.buttonPrimary,
|
||
foregroundColor: context.conduitTheme.buttonPrimaryText,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Spacing.lg,
|
||
vertical: Spacing.sm,
|
||
),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(
|
||
AppBorderRadius.button,
|
||
),
|
||
),
|
||
),
|
||
child: Text(_index == _pages.length - 1 ? 'Done' : 'Next'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _OnboardingPage {
|
||
final String title;
|
||
final String subtitle;
|
||
final IconData icon;
|
||
final List<String>? bullets;
|
||
const _OnboardingPage({
|
||
required this.title,
|
||
required this.subtitle,
|
||
required this.icon,
|
||
this.bullets,
|
||
});
|
||
}
|
||
|
||
class _IllustratedPage extends StatelessWidget {
|
||
final _OnboardingPage page;
|
||
const _IllustratedPage({required this.page});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// Aurora blob illustration
|
||
SizedBox(
|
||
height: 160,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Positioned(top: 10, left: 24, child: _blob(context, 90, 0.18)),
|
||
Positioned(
|
||
bottom: 0,
|
||
right: 16,
|
||
child: _blob(context, 130, 0.12),
|
||
),
|
||
Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
color: context.conduitTheme.buttonPrimary,
|
||
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
|
||
boxShadow: ConduitShadows.glow,
|
||
),
|
||
child: Icon(page.icon, color: context.conduitTheme.textInverse),
|
||
).animate().scale(duration: AnimationDuration.fast),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: Spacing.lg),
|
||
Text(
|
||
page.title,
|
||
style: TextStyle(
|
||
fontSize: AppTypography.headlineMedium,
|
||
fontWeight: FontWeight.w700,
|
||
color: context.conduitTheme.textPrimary,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: Spacing.sm),
|
||
Text(
|
||
page.subtitle,
|
||
style: TextStyle(
|
||
fontSize: AppTypography.bodyLarge,
|
||
color: context.conduitTheme.textSecondary,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
if (page.bullets != null && page.bullets!.isNotEmpty) ...[
|
||
const SizedBox(height: Spacing.md),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: page.bullets!
|
||
.map(
|
||
(b) => Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: Spacing.lg,
|
||
vertical: 4,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
margin: const EdgeInsets.only(top: 8, right: 8),
|
||
decoration: BoxDecoration(
|
||
color: context.conduitTheme.buttonPrimary,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
b,
|
||
style: TextStyle(
|
||
color: context.conduitTheme.textSecondary,
|
||
fontSize: AppTypography.bodyMedium,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
.toList(),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _blob(BuildContext context, double size, double alpha) {
|
||
return Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: context.conduitTheme.buttonPrimary.withValues(alpha: alpha),
|
||
boxShadow: ConduitShadows.glow,
|
||
),
|
||
);
|
||
}
|
||
}
|