feat: enhance UI, branding assets, onboarding flow, and screen layouts
This commit is contained in:
@@ -7,8 +7,11 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../core/permissions.dart';
|
||||
import '../state/session_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
import 'today_screen.dart';
|
||||
import '../widgets/krow_nav_bar.dart';
|
||||
|
||||
/// Step 1 of 2: the mobile number a shift manager would text.
|
||||
class PhoneScreen extends ConsumerStatefulWidget {
|
||||
@@ -456,18 +459,92 @@ class _TodoRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// One permission ask, as the prototype words it.
|
||||
class _Permission {
|
||||
const _Permission(this.icon, this.step, this.title, this.body, this.reasons);
|
||||
const _Permission({
|
||||
required this.icon,
|
||||
required this.step,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.reasons,
|
||||
required this.allow,
|
||||
required this.defer,
|
||||
required this.permission,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String step;
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> reasons;
|
||||
|
||||
/// Each reason as `(before, emphasis, after)` — the prototype bolds the part
|
||||
/// that says what you actually get.
|
||||
final List<(String, String, String)> reasons;
|
||||
final String allow;
|
||||
|
||||
/// The way out. Worded differently each time, per the prototype.
|
||||
final String defer;
|
||||
|
||||
/// What the OS is actually asked for when Allow is tapped.
|
||||
final AppPermission permission;
|
||||
}
|
||||
|
||||
/// The three-step permission run. Each grant is recorded in the session so the
|
||||
/// app knows what it is allowed to do later.
|
||||
const _permissions = [
|
||||
_Permission(
|
||||
icon: LucideIcons.mapPin,
|
||||
step: 'Step 1 of 3',
|
||||
title: 'Let KROW see where you are',
|
||||
body:
|
||||
'Clock-in is locked until you are within 150 m of the venue. Without '
|
||||
'location you cannot start a shift.',
|
||||
reasons: [
|
||||
('Unlocks ', 'clock in', ' when you arrive'),
|
||||
('Sorts shifts by ', 'distance from you', ''),
|
||||
('Only read ', 'while a shift is active', ''),
|
||||
],
|
||||
allow: 'Allow location',
|
||||
defer: 'Not now',
|
||||
permission: AppPermission.location,
|
||||
),
|
||||
_Permission(
|
||||
icon: LucideIcons.bell,
|
||||
step: 'Step 2 of 3',
|
||||
title: 'Turn on notifications',
|
||||
body:
|
||||
'Shift offers are first-come. If your phone is silent someone else '
|
||||
'takes the shift.',
|
||||
reasons: [
|
||||
('', 'New shift offers', ' matched to you'),
|
||||
('', 'Clock-in reminders', ' 30 min before'),
|
||||
('', 'Pay alerts', ' when money lands'),
|
||||
],
|
||||
allow: 'Turn on notifications',
|
||||
defer: 'Maybe later',
|
||||
permission: AppPermission.notifications,
|
||||
),
|
||||
_Permission(
|
||||
icon: LucideIcons.camera,
|
||||
step: 'Step 3 of 3',
|
||||
title: 'Camera and photos',
|
||||
body:
|
||||
'Attire and document checks need a photo of the real item. Nothing is '
|
||||
'uploaded until you tap send.',
|
||||
reasons: [
|
||||
('Photograph ', 'your uniform items', ''),
|
||||
('Capture ', 'ID and certificates', ''),
|
||||
('Nothing is stored ', 'on our servers unsent', ''),
|
||||
],
|
||||
allow: 'Allow camera',
|
||||
defer: 'Choose from library instead',
|
||||
permission: AppPermission.camera,
|
||||
),
|
||||
];
|
||||
|
||||
/// The three permission asks, each a sheet over the home screen.
|
||||
///
|
||||
/// Shown this way rather than as full pages because that is what the worker
|
||||
/// is actually looking at: the app is already there behind the scrim, so the
|
||||
/// ask reads as a step before using it rather than another sign-up screen.
|
||||
class PermissionsScreen extends ConsumerStatefulWidget {
|
||||
const PermissionsScreen({super.key});
|
||||
|
||||
@@ -477,58 +554,9 @@ class PermissionsScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
||||
int _step = 0;
|
||||
bool _asking = false;
|
||||
|
||||
static const _permissions = [
|
||||
_Permission(
|
||||
LucideIcons.mapPin,
|
||||
'Step 1 of 3',
|
||||
'Let KROW see where you are',
|
||||
'Clock-in is locked until you are within 150 m of the venue. Without '
|
||||
'location you cannot start a shift.',
|
||||
[
|
||||
'Unlocks clock in when you arrive',
|
||||
'Sorts shifts by distance from you',
|
||||
'Only read while a shift is active',
|
||||
],
|
||||
),
|
||||
_Permission(
|
||||
LucideIcons.bell,
|
||||
'Step 2 of 3',
|
||||
'Turn on notifications',
|
||||
'Shift offers are first-come. If your phone is silent someone else takes '
|
||||
'the shift.',
|
||||
[
|
||||
'New shift offers matched to you',
|
||||
'Clock-in reminders 30 min before',
|
||||
'Pay alerts when money lands',
|
||||
],
|
||||
),
|
||||
_Permission(
|
||||
LucideIcons.camera,
|
||||
'Step 3 of 3',
|
||||
'Camera and photos',
|
||||
'Attire and document checks need a photo of the real item. Nothing is '
|
||||
'uploaded until you tap send.',
|
||||
[
|
||||
'Photograph your uniform items',
|
||||
'Capture ID and certificates',
|
||||
'Nothing is stored on our servers unsent',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void _advance({required bool granted}) {
|
||||
final notifier = ref.read(sessionProvider.notifier);
|
||||
if (granted) {
|
||||
switch (_step) {
|
||||
case 0:
|
||||
notifier.grantLocation();
|
||||
case 1:
|
||||
notifier.grantNotifications();
|
||||
case 2:
|
||||
notifier.grantCamera();
|
||||
}
|
||||
}
|
||||
void _next() {
|
||||
if (_step == _permissions.length - 1) {
|
||||
context.go('/today');
|
||||
} else {
|
||||
@@ -536,115 +564,267 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands off to the operating system's own dialog. Whatever it answers is
|
||||
/// recorded, and the run continues either way — a refused permission is a
|
||||
/// smaller problem than a worker stuck on a sign-up step.
|
||||
Future<void> _allow(_Permission permission) async {
|
||||
if (_asking) return;
|
||||
setState(() => _asking = true);
|
||||
final granted = await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.request(permission.permission);
|
||||
if (!mounted) return;
|
||||
setState(() => _asking = false);
|
||||
|
||||
if (!granted) {
|
||||
final blocked = await ref
|
||||
.read(permissionServiceProvider)
|
||||
.isPermanentlyDenied(permission.permission);
|
||||
// Once the OS stops showing the dialog, tapping Allow again does
|
||||
// nothing — the only way back is the system settings page.
|
||||
if (blocked && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Turn this on in system settings'),
|
||||
action: SnackBarAction(
|
||||
label: 'Open',
|
||||
textColor: KrowPalette.yellow,
|
||||
onPressed: ref.read(permissionServiceProvider).openSettings,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted) _next();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _permissions[_step];
|
||||
final permission = _permissions[_step];
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The copy scrolls if it has to; the actions stay pinned, so a
|
||||
// short screen or a large text scale can never push them off.
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blue,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(p.icon, size: 27, color: Colors.white),
|
||||
backgroundColor: KrowPalette.sage,
|
||||
body: Stack(
|
||||
children: [
|
||||
// The app itself, already built, sitting behind the ask.
|
||||
const IgnorePointer(
|
||||
child: Scaffold(
|
||||
body: TodayScreen(),
|
||||
bottomNavigationBar: KrowNavBar(
|
||||
items: [
|
||||
KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
|
||||
KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
|
||||
KrowNavItem(icon: LucideIcons.house, label: 'Today'),
|
||||
KrowNavItem(icon: LucideIcons.clock, label: 'Clock'),
|
||||
KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
|
||||
],
|
||||
currentIndex: 2,
|
||||
onSelect: _ignoreTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
const ModalBarrier(color: Color(0x8F101A14), dismissible: false),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: _PermissionSheet(
|
||||
permission: permission,
|
||||
busy: _asking,
|
||||
onAllow: () => _allow(permission),
|
||||
onDefer: _next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _ignoreTap(int _) {}
|
||||
}
|
||||
|
||||
class _PermissionSheet extends StatelessWidget {
|
||||
const _PermissionSheet({
|
||||
required this.permission,
|
||||
required this.busy,
|
||||
required this.onAllow,
|
||||
required this.onDefer,
|
||||
});
|
||||
|
||||
final _Permission permission;
|
||||
final bool busy;
|
||||
final VoidCallback onAllow;
|
||||
final VoidCallback onDefer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ConstrainedBox(
|
||||
// Scrolls rather than overflows if the copy outgrows the screen.
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 12, 22, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFCFDDD4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
SectionLabel(p.step),
|
||||
Text(
|
||||
p.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
letterSpacing: -0.9,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
p.body,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
for (final reason in p.reasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
// Nudged to sit on the first line's optical
|
||||
// centre, so wrapped reasons stay top-aligned.
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 12,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
reason,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Aligned rather than stretched: the surrounding column
|
||||
// stretches its children so the buttons fill the sheet, and
|
||||
// that would pull this 60px tile across the whole width.
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF4A69C6), KrowPalette.blue],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: KrowPalette.blue.withValues(alpha: 0.4),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
permission.icon,
|
||||
size: 27,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
SectionLabel(permission.step),
|
||||
Text(
|
||||
permission.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
letterSpacing: -0.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
permission.body,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (final (before, emphasis, after) in permission.reasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _Reason(
|
||||
before: before,
|
||||
emphasis: emphasis,
|
||||
after: after,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FilledButton(
|
||||
onPressed: busy ? null : onAllow,
|
||||
child: Text(permission.allow),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDefer,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
foregroundColor: KrowPalette.mute,
|
||||
),
|
||||
child: Text(permission.defer),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => _advance(granted: true),
|
||||
child: Text(switch (_step) {
|
||||
0 => 'Allow location',
|
||||
1 => 'Turn on notifications',
|
||||
_ => 'Allow camera',
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Full width so the secondary action shares the primary's edges
|
||||
// rather than hugging the left.
|
||||
TextButton(
|
||||
onPressed: () => _advance(granted: false),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Not now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Reason extends StatelessWidget {
|
||||
const _Reason({
|
||||
required this.before,
|
||||
required this.emphasis,
|
||||
required this.after,
|
||||
});
|
||||
|
||||
final String before;
|
||||
final String emphasis;
|
||||
final String after;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 11,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: before),
|
||||
TextSpan(
|
||||
text: emphasis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
TextSpan(text: after),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user