Initial commit: Krow Worker App

This commit is contained in:
R-Bharathraj
2026-09-17 22:26:11 +05:30
commit cafffd27e0
113 changed files with 13067 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:krow_worker_app/core/theme.dart';
import 'package:krow_worker_app/screens/clock_screen.dart';
import 'package:krow_worker_app/screens/pay_screens.dart';
import 'package:krow_worker_app/screens/profile_screen.dart';
import 'package:krow_worker_app/screens/shifts_screen.dart';
import 'package:krow_worker_app/screens/today_screen.dart';
import 'package:krow_worker_app/state/session_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Overflow is reported as a thrown FlutterError, and the stripes only appear
/// in a debug build — so a release web check cannot see it and it reaches a
/// device first. These pump every tab at real phone sizes to catch it here.
void main() {
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
});
Future<void> pumpScreen(
WidgetTester tester,
Widget screen, {
required Size size,
double textScale = 1.0,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
// Captured rather than left to the default handler, so a failure names the
// widget that overflowed instead of just the pixel count.
final errors = <FlutterErrorDetails>[];
final previousHandler = FlutterError.onError;
FlutterError.onError = errors.add;
await tester.pumpWidget(
ProviderScope(
overrides: [prefsProvider.overrideWithValue(prefs)],
child: MaterialApp(
theme: buildKrowTheme(),
home: MediaQuery(
data: MediaQueryData(
size: size,
textScaler: TextScaler.linear(textScale),
// A notch, so the header has to make room for it.
padding: const EdgeInsets.only(top: 44, bottom: 34),
),
child: screen,
),
),
),
);
await tester.pump();
FlutterError.onError = previousHandler;
if (errors.isNotEmpty) {
fail(errors.map((e) => e.toString()).join('\n\n'));
}
// Screens with running timers must be torn down, or the test fails on a
// pending timer rather than on what it is checking.
await tester.pumpWidget(const SizedBox.shrink());
}
final screens = <String, Widget>{
'Today': const TodayScreen(),
'Shifts': const ShiftsScreen(),
'Clock': const ClockScreen(),
'Pay': const PayScreen(),
'Profile': const ProfileScreen(),
};
// A small phone, a tall one, and a short-but-wide one.
final sizes = <String, Size>{
'small phone': const Size(360, 640),
'tall phone': const Size(412, 915),
'compact': const Size(320, 568),
};
for (final screen in screens.entries) {
for (final size in sizes.entries) {
testWidgets('${screen.key} lays out on a ${size.key}', (tester) async {
await pumpScreen(tester, screen.value, size: size.value);
});
}
testWidgets('${screen.key} survives large system text', (tester) async {
await pumpScreen(
tester,
screen.value,
size: const Size(360, 640),
textScale: 1.3,
);
});
}
}

128
test/shift_logic_test.dart Normal file
View File

@@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:krow_worker_app/core/formatters.dart';
import 'package:krow_worker_app/models/shift.dart';
import 'package:krow_worker_app/models/timecard.dart';
Shift buildShift({
ShiftStatus status = ShiftStatus.booked,
DateTime? clockIn,
DateTime? clockOut,
int breakMinutes = 30,
}) {
final start = DateTime(2026, 8, 10, 17, 50);
return Shift(
id: 's1',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd',
start: start,
end: start.add(const Duration(hours: 6)),
rate: 21.00,
status: status,
breakMinutes: breakMinutes,
clockIn: clockIn,
clockOut: clockOut,
);
}
void main() {
group('Shift pay', () {
test('scheduled pay deducts the unpaid break', () {
final shift = buildShift();
expect(shift.scheduledDuration, const Duration(hours: 5, minutes: 30));
expect(shift.scheduledPay, closeTo(115.50, 0.001));
});
test('worked hours replace the schedule once clocked out', () {
final shift = buildShift(
status: ShiftStatus.completed,
clockIn: DateTime(2026, 8, 10, 18, 1),
clockOut: DateTime(2026, 8, 10, 23, 50),
);
// 5h49m on the clock, less the 30 minute break.
expect(shift.workedDuration, const Duration(hours: 5, minutes: 19));
expect(shift.pay, closeTo(111.65, 0.01));
});
test('a clocked window shorter than the break never goes negative', () {
final shift = buildShift(
status: ShiftStatus.completed,
clockIn: DateTime(2026, 8, 10, 18, 0),
clockOut: DateTime(2026, 8, 10, 18, 5),
);
expect(shift.workedDuration, Duration.zero);
expect(shift.pay, 0);
});
test('cancelled and no-show shifts generate no pay', () {
expect(buildShift(status: ShiftStatus.cancelled).pay, 0);
expect(buildShift(status: ShiftStatus.noShow).pay, 0);
});
});
group('Timecard', () {
Timecard card({required DateTime clockIn}) {
final start = DateTime(2026, 8, 10, 17, 50);
return Timecard(
id: 'tc1',
shiftId: 's1',
role: 'Barista',
business: 'Zoox',
scheduledStart: start,
scheduledEnd: start.add(const Duration(hours: 6)),
clockIn: clockIn,
clockOut: DateTime(2026, 8, 10, 23, 50),
breakMinutes: 30,
rate: 21.00,
status: TimecardStatus.needsReview,
submittedAt: DateTime(2026, 8, 11, 0, 2),
);
}
test('variance is positive when you clock in late', () {
expect(card(clockIn: DateTime(2026, 8, 10, 18, 1)).varianceMinutes, 11);
});
test('variance is negative when you clock in early', () {
expect(card(clockIn: DateTime(2026, 8, 10, 17, 45)).varianceMinutes, -5);
});
test('amount follows the clocked hours', () {
expect(
card(clockIn: DateTime(2026, 8, 10, 18, 1)).amount,
closeTo(111.65, 0.01),
);
});
});
group('Formatters', () {
test('duration reads as hours and minutes', () {
expect(Fmt.duration(const Duration(hours: 5, minutes: 19)), '5h 19m');
expect(Fmt.duration(const Duration(minutes: 45)), '45m');
});
test('stopwatch pads every field', () {
expect(
Fmt.stopwatch(const Duration(hours: 1, minutes: 2, seconds: 3)),
'01:02:03',
);
});
test('countdown switches unit as the shift approaches', () {
final now = DateTime(2026, 8, 10, 12, 0);
expect(
Fmt.untilStart(now.add(const Duration(minutes: 41)), now: now),
'In 41 min',
);
expect(
Fmt.untilStart(now.add(const Duration(hours: 3)), now: now),
'In 3 hr',
);
expect(
Fmt.untilStart(now.subtract(const Duration(minutes: 5)), now: now),
'Started',
);
});
});
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:krow_worker_app/core/transitions.dart';
const _detail = Key('detail');
GoRouter buildRouter() => GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, _) => const Scaffold(body: Text('home')),
),
GoRoute(
path: '/detail',
pageBuilder: (_, state) => Transitions.push(
state,
const Scaffold(body: SizedBox.expand(key: _detail)),
),
),
GoRoute(
path: '/replaced',
pageBuilder: (_, state) => Transitions.fade(
state,
const Scaffold(body: SizedBox.expand(key: _detail)),
),
),
],
);
void main() {
group('Transitions.push', () {
testWidgets('settles flush against the left edge', (tester) async {
final router = buildRouter();
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
router.push('/detail');
await tester.pumpAndSettle();
// The page is driven by two animations at once, and its resting state
// comes from the one that is *not* running. Reversing that tween leaves
// the screen parked off to the side, which is easy to miss in code and
// obvious on a phone.
final rect = tester.getRect(find.byKey(_detail));
expect(rect.left, 0);
expect(
rect.width,
tester.view.physicalSize.width / tester.view.devicePixelRatio,
);
});
testWidgets('enters from the right rather than appearing', (tester) async {
final router = buildRouter();
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
router.push('/detail');
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
expect(tester.getRect(find.byKey(_detail)).left, greaterThan(0));
});
});
group('Transitions.fade', () {
testWidgets('settles flush and fully opaque', (tester) async {
final router = buildRouter();
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
router.push('/replaced');
await tester.pumpAndSettle();
expect(tester.getRect(find.byKey(_detail)).left, 0);
final fade = tester.widget<FadeTransition>(
find
.ancestor(
of: find.byKey(_detail),
matching: find.byType(FadeTransition),
)
.first,
);
expect(fade.opacity.value, 1);
});
});
}

195
test/widgets_test.dart Normal file
View File

@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:krow_worker_app/core/theme.dart';
import 'package:krow_worker_app/widgets/common.dart';
import 'package:krow_worker_app/widgets/glass_segments.dart';
import 'package:krow_worker_app/widgets/krow_nav_bar.dart';
Widget wrap(Widget child) => MaterialApp(
theme: buildKrowTheme(),
home: Scaffold(body: child),
);
void main() {
group('KrowNavBar', () {
const items = [
KrowNavItem(icon: Icons.home, label: 'Today'),
KrowNavItem(icon: Icons.work, label: 'Shifts'),
KrowNavItem(icon: Icons.timer, label: 'Clock', showDot: true),
];
testWidgets('renders every label', (tester) async {
await tester.pumpWidget(
wrap(KrowNavBar(items: items, currentIndex: 0, onSelect: (_) {})),
);
expect(find.text('Today'), findsOneWidget);
expect(find.text('Shifts'), findsOneWidget);
expect(find.text('Clock'), findsOneWidget);
});
testWidgets('reports the tapped index', (tester) async {
var selected = -1;
await tester.pumpWidget(
wrap(
KrowNavBar(
items: items,
currentIndex: 0,
onSelect: (i) => selected = i,
),
),
);
await tester.tap(find.text('Clock'));
expect(selected, 2);
});
testWidgets('announces a tab that needs attention', (tester) async {
await tester.pumpWidget(
wrap(KrowNavBar(items: items, currentIndex: 0, onSelect: (_) {})),
);
// The dot is not readable, so the state has to reach the semantics tree.
expect(find.bySemanticsLabel('Clock, needs attention'), findsOneWidget);
expect(find.bySemanticsLabel('Today'), findsOneWidget);
});
testWidgets('the indicator travels rather than jumping', (tester) async {
var index = 0;
await tester.pumpWidget(
wrap(
StatefulBuilder(
builder: (context, setState) => KrowNavBar(
items: items,
currentIndex: index,
onSelect: (i) => setState(() => index = i),
),
),
),
);
Rect pillRect() => tester.getRect(find.byKey(KrowNavBar.indicatorKey));
final start = pillRect().center.dx;
await tester.tap(find.text('Clock'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 120));
// Part way through it should be between the two tabs, not already there.
final middle = pillRect().center.dx;
expect(middle, greaterThan(start));
await tester.pumpAndSettle();
final end = pillRect().center.dx;
expect(end, greaterThan(middle));
});
});
group('StatSlabRow', () {
// Every real use sits in a scrolling column, where the height is
// unbounded. Testing it against a bounded Scaffold body hides exactly the
// layout errors this row is prone to.
Widget inScrollable(Widget child) =>
wrap(ListView(children: [const SizedBox(height: 8), child]));
testWidgets('lays out inside a scrollable', (tester) async {
await tester.pumpWidget(
inScrollable(
const StatSlabRow(
slabs: [
StatSlab('Clock in', '6:01 PM'),
StatSlab('Clock out', '11:50 PM'),
StatSlab('Hours', '5h 19m'),
],
),
),
);
expect(tester.takeException(), isNull);
// A row that tried to fill unbounded height would be enormous.
expect(tester.getSize(find.byType(StatSlabRow)).height, lessThan(120));
});
testWidgets('keeps every card the same height, however long the text', (
tester,
) async {
await tester.pumpWidget(
inScrollable(
const StatSlabRow(
slabs: [
// The middle one is what used to wrap on both lines and push
// its card taller than the two beside it.
StatSlab('Clock in', '6:01 PM'),
StatSlab('Clock out', '11:50 PM'),
StatSlab('Hours', '5h 19m'),
],
),
),
);
final heights = tester
.widgetList<StatSlab>(find.byType(StatSlab))
.map((slab) => tester.getSize(find.byWidget(slab)).height)
.toSet();
expect(heights.length, 1, reason: 'cards should be one height');
});
testWidgets('scales long values down instead of wrapping', (tester) async {
await tester.pumpWidget(
inScrollable(
const StatSlabRow(
slabs: [
StatSlab('Paid', r'$1,234.56'),
StatSlab('Rate', r'$21.00/hr'),
StatSlab('Hours', '5h 19m'),
],
),
),
);
for (final text in tester.widgetList<Text>(find.byType(Text))) {
expect(text.maxLines, 1);
}
});
});
group('GlassSegments', () {
testWidgets('reports the tapped segment', (tester) async {
String? picked;
await tester.pumpWidget(
wrap(
GlassSegments<String>(
segments: const [('a', 'Week'), ('b', 'Month'), ('c', 'Year')],
value: 'a',
onChanged: (v) => picked = v,
),
),
);
await tester.tap(find.text('Year'));
expect(picked, 'c');
});
testWidgets('ignores a tap on the segment already selected', (
tester,
) async {
var calls = 0;
await tester.pumpWidget(
wrap(
GlassSegments<String>(
segments: const [('a', 'Week'), ('b', 'Month')],
value: 'a',
onChanged: (_) => calls++,
),
),
);
await tester.tap(find.text('Week'));
expect(calls, 0);
await tester.tap(find.text('Month'));
expect(calls, 1);
});
});
}