619 lines
20 KiB
Dart
619 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../core/permissions.dart';
|
|
import '../core/theme.dart';
|
|
import '../state/profile_controller.dart';
|
|
import '../state/seed.dart';
|
|
import '../state/session_controller.dart';
|
|
import '../state/shifts_controller.dart';
|
|
import '../state/timecards_controller.dart';
|
|
import '../widgets/common.dart';
|
|
|
|
class SettingsScreen extends ConsumerStatefulWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// A permission can be revoked in system settings while the app is closed,
|
|
// so the switches are re-read from the OS rather than trusted from cache.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
ref.read(sessionProvider.notifier).refresh();
|
|
});
|
|
}
|
|
|
|
/// The OS has no API to revoke a permission, and re-requesting a granted one
|
|
/// does nothing — so the switch hands off to the system settings page for
|
|
/// anything it cannot do itself.
|
|
Future<void> _toggle(AppPermission permission, bool value) async {
|
|
final notifier = ref.read(sessionProvider.notifier);
|
|
if (!value) {
|
|
await ref.read(permissionServiceProvider).openSettings();
|
|
return;
|
|
}
|
|
final granted = await notifier.request(permission);
|
|
if (granted || !mounted) return;
|
|
await ref.read(permissionServiceProvider).openSettings();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final session = ref.watch(sessionProvider);
|
|
final profile = ref.watch(profileProvider);
|
|
final reviewCount = ref.watch(timecardsNeedingReviewProvider).length;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Settings'),
|
|
actions: const [
|
|
Padding(
|
|
padding: EdgeInsets.only(right: 16),
|
|
child: Center(child: StatusChip('v1.1.1', tone: ChipTone.yellow)),
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
|
children: [
|
|
const SectionLabel('Permissions'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
_SwitchRow(
|
|
icon: LucideIcons.mapPin,
|
|
title: 'Location',
|
|
subtitle: 'Needed to clock in at the venue',
|
|
value: session.locationGranted,
|
|
onChanged: (v) => _toggle(AppPermission.location, v),
|
|
),
|
|
const Divider(height: 1),
|
|
_SwitchRow(
|
|
icon: LucideIcons.bell,
|
|
title: 'Notifications',
|
|
subtitle: 'Shift offers, reminders and pay alerts',
|
|
value: session.notificationsGranted,
|
|
onChanged: (v) => _toggle(AppPermission.notifications, v),
|
|
),
|
|
const Divider(height: 1),
|
|
_SwitchRow(
|
|
icon: LucideIcons.camera,
|
|
title: 'Camera',
|
|
subtitle: 'Photograph documents and attire',
|
|
value: session.cameraGranted,
|
|
onChanged: (v) => _toggle(AppPermission.camera, v),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SectionLabel('Money'),
|
|
TileGroup(
|
|
children: [
|
|
NavTile(
|
|
icon: LucideIcons.landmark,
|
|
title: 'Direct deposit',
|
|
subtitle: profile.bank == null
|
|
? 'Where your pay lands'
|
|
: profile.bank!.bankName,
|
|
badge: profile.bank == null
|
|
? const StatusChip('Add', tone: ChipTone.yellow)
|
|
: null,
|
|
onTap: () => context.push('/direct-deposit'),
|
|
),
|
|
NavTile(
|
|
icon: LucideIcons.clock,
|
|
title: 'Timecards',
|
|
subtitle: 'Hours worked and disputes',
|
|
badge: reviewCount > 0
|
|
? StatusChip('$reviewCount', tone: ChipTone.yellow)
|
|
: null,
|
|
onTap: () => context.push('/timecards'),
|
|
),
|
|
NavTile(
|
|
icon: LucideIcons.calendarDays,
|
|
title: 'Payroll schedule',
|
|
subtitle: 'When each period closes and pays',
|
|
onTap: () => context.push('/payroll'),
|
|
),
|
|
],
|
|
),
|
|
const SectionLabel('Support'),
|
|
TileGroup(
|
|
children: [
|
|
NavTile(
|
|
icon: LucideIcons.lifeBuoy,
|
|
title: 'Contact support',
|
|
subtitle: 'Usually answered within 4 hours',
|
|
onTap: () => context.push('/support'),
|
|
),
|
|
NavTile(
|
|
icon: LucideIcons.circleHelp,
|
|
title: 'FAQs',
|
|
subtitle: 'Common questions answered',
|
|
onTap: () => context.push('/faqs'),
|
|
),
|
|
NavTile(
|
|
icon: LucideIcons.shield,
|
|
title: 'Privacy & security',
|
|
subtitle: 'Account and data settings',
|
|
onTap: () => context.push('/privacy'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 22),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _signOut(context, ref),
|
|
icon: const Icon(LucideIcons.logOut, size: 18),
|
|
label: const Text('Sign out'),
|
|
),
|
|
const SizedBox(height: 22),
|
|
const Center(
|
|
child: Text(
|
|
'VERSION 1.1.1 · BUILD 26',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
letterSpacing: 1.2,
|
|
color: Color(0xFF949FA5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Sign out?'),
|
|
content: const Text('You will need to verify your number again.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Stay'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
child: const Text('Sign out'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true || !context.mounted) return;
|
|
await ref.read(sessionProvider.notifier).signOut();
|
|
if (!context.mounted) return;
|
|
context.go('/welcome');
|
|
}
|
|
}
|
|
|
|
class _SwitchRow extends StatelessWidget {
|
|
const _SwitchRow({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.blueTint,
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
child: Icon(icon, size: 18, color: KrowPalette.blue),
|
|
),
|
|
const SizedBox(width: 13),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 14.5,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(value: value, onChanged: onChanged),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pick what it is about so it reaches the right person first time.
|
|
class SupportScreen extends ConsumerStatefulWidget {
|
|
const SupportScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<SupportScreen> createState() => _SupportScreenState();
|
|
}
|
|
|
|
class _SupportScreenState extends ConsumerState<SupportScreen> {
|
|
String _topic = Seed.supportTopics.first;
|
|
String? _shiftId;
|
|
final _message = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_message.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _send() {
|
|
if (_message.text.trim().length < 10) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Tell us a little more than that')),
|
|
);
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Sent to support · $_topic')));
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final shifts = ref.watch(shiftsProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Get help'),
|
|
actions: const [
|
|
Padding(
|
|
padding: EdgeInsets.only(right: 16),
|
|
child: Center(child: StatusChip('~4 hr reply')),
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
|
children: [
|
|
const Text(
|
|
'Pick what it is about so it reaches the right person first time.',
|
|
style: TextStyle(
|
|
fontSize: 13.5,
|
|
height: 1.55,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
const SectionLabel("What's it about?"),
|
|
Wrap(
|
|
spacing: 7,
|
|
runSpacing: 7,
|
|
children: [
|
|
for (final t in Seed.supportTopics)
|
|
ChoiceChip(
|
|
label: Text(t),
|
|
selected: t == _topic,
|
|
showCheckmark: false,
|
|
selectedColor: KrowPalette.blue,
|
|
labelStyle: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w500,
|
|
color: t == _topic ? Colors.white : KrowPalette.mute,
|
|
),
|
|
onSelected: (_) => setState(() => _topic = t),
|
|
),
|
|
],
|
|
),
|
|
const SectionLabel('Which shift?'),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _shiftId,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Not about a specific shift',
|
|
),
|
|
items: [
|
|
for (final s in shifts)
|
|
DropdownMenuItem(
|
|
value: s.id,
|
|
child: Text(
|
|
'${s.role} · ${s.business}',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
onChanged: (v) => setState(() => _shiftId = v),
|
|
),
|
|
const SectionLabel('Tell us what happened'),
|
|
TextField(
|
|
controller: _message,
|
|
maxLines: 5,
|
|
maxLength: 500,
|
|
decoration: const InputDecoration(
|
|
hintText: 'The venue door was locked when I arrived...',
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Attachments open the camera roll')),
|
|
),
|
|
icon: const Icon(LucideIcons.paperclip, size: 16),
|
|
label: const Text('Attach a photo'),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FilledButton(onPressed: _send, child: const Text('Send to support')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class FaqScreen extends StatefulWidget {
|
|
const FaqScreen({super.key});
|
|
|
|
@override
|
|
State<FaqScreen> createState() => _FaqScreenState();
|
|
}
|
|
|
|
class _FaqScreenState extends State<FaqScreen> {
|
|
String _query = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final q = _query.toLowerCase();
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Questions')),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
|
children: [
|
|
TextField(
|
|
onChanged: (v) => setState(() => _query = v),
|
|
decoration: const InputDecoration(
|
|
hintText: 'Search help',
|
|
prefixIcon: Icon(LucideIcons.search, size: 18),
|
|
),
|
|
),
|
|
for (final entry in Seed.faqs.entries)
|
|
() {
|
|
final matches = entry.value
|
|
.where(
|
|
(f) =>
|
|
q.isEmpty ||
|
|
f.$1.toLowerCase().contains(q) ||
|
|
f.$2.toLowerCase().contains(q),
|
|
)
|
|
.toList();
|
|
if (matches.isEmpty) return const SizedBox.shrink();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SectionLabel(entry.key),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < matches.length; i++) ...[
|
|
if (i > 0) const Divider(height: 1),
|
|
ExpansionTile(
|
|
shape: const Border(),
|
|
collapsedShape: const Border(),
|
|
tilePadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
),
|
|
childrenPadding: const EdgeInsets.fromLTRB(
|
|
12,
|
|
0,
|
|
12,
|
|
14,
|
|
),
|
|
title: Text(
|
|
matches[i].$1,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
matches[i].$2,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
height: 1.55,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PrivacyScreen extends ConsumerWidget {
|
|
const PrivacyScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final profile = ref.watch(profileProvider);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Privacy & security')),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
|
children: [
|
|
const SectionLabel('Legal'),
|
|
TileGroup(
|
|
children: [
|
|
NavTile(
|
|
icon: LucideIcons.fileText,
|
|
title: 'Terms of service',
|
|
subtitle: 'Updated 14 Jun 2026',
|
|
onTap: () => _open(context, 'Terms of service'),
|
|
),
|
|
NavTile(
|
|
icon: LucideIcons.shield,
|
|
title: 'Privacy policy',
|
|
subtitle: 'What we collect and why',
|
|
onTap: () => _open(context, 'Privacy policy'),
|
|
),
|
|
],
|
|
),
|
|
const SectionLabel('Who sees your data'),
|
|
TileGroup(
|
|
children: [
|
|
NavTile(
|
|
icon: LucideIcons.briefcase,
|
|
title: "Businesses you've worked for",
|
|
subtitle: '2 have access to your documents',
|
|
onTap: () => _open(context, 'Data sharing'),
|
|
),
|
|
],
|
|
),
|
|
const SectionLabel('Email'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.blueTint,
|
|
borderRadius: BorderRadius.circular(11),
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.mail,
|
|
size: 18,
|
|
color: KrowPalette.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 13),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'KROW newsletter',
|
|
style: TextStyle(
|
|
fontSize: 14.5,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
'Product news and operational tips',
|
|
style: TextStyle(fontSize: 12, color: KrowPalette.mute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Switch(
|
|
value: profile.newsletter,
|
|
onChanged: (v) =>
|
|
ref.read(profileProvider.notifier).setNewsletter(v),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SectionLabel('Account'),
|
|
KrowCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const Text(
|
|
'Delete your account',
|
|
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 3),
|
|
const Text(
|
|
'Permanent. Timecards and tax records are kept for 4 years '
|
|
'by law, then destroyed.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
height: 1.45,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _confirmDelete(context),
|
|
icon: const Icon(LucideIcons.trash2, size: 18),
|
|
label: const Text('Delete account'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _open(BuildContext context, String title) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('$title opens in the browser')));
|
|
}
|
|
|
|
Future<void> _confirmDelete(BuildContext context) async {
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete your account?'),
|
|
content: const Text(
|
|
'This cannot be undone. To go ahead, contact support so we can '
|
|
'confirm it is really you.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Keep my account'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
context.push('/support');
|
|
},
|
|
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
child: const Text('Contact support'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|