Initial commit: Krow Worker App
This commit is contained in:
100
test/screen_layout_test.dart
Normal file
100
test/screen_layout_test.dart
Normal 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
128
test/shift_logic_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
88
test/transitions_test.dart
Normal file
88
test/transitions_test.dart
Normal 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
195
test/widgets_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user