Skip to content

Commit 64b623c

Browse files
committed
feat: improve coverage and resolve problems to generate the img
1 parent 2b5551f commit 64b623c

11 files changed

Lines changed: 416 additions & 51 deletions

lib/data/pictogram_data.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class KeyboardCategory {
99
}
1010

1111
const String spaceIcon = '__space__';
12-
const int defaultBoardMaxIcons = 10;
12+
const int defaultBoardMaxIcons = 4;
1313
const int maxRecommendationIcons = 6;
1414

1515
const List<String> descriptiveIcons = [

lib/screens/pictotap_screen.dart

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import 'dart:async';
2+
import 'dart:math' as math;
3+
import 'dart:typed_data';
24
import 'dart:ui' as ui;
35
import 'package:flutter/material.dart';
46
import 'package:flutter/rendering.dart';
@@ -28,7 +30,9 @@ class _PictoTapScreenState extends State<PictoTapScreen> {
2830

2931
static const String _backgroundAssetPath =
3032
'assets/background/background.webp';
31-
static const double _boardIconSize = 80;
33+
static const double _boardIconSize = 110;
34+
static const int _shareImageSize = 1080;
35+
static const Color _backgroundFallback = Color(0xFFF5F0EB);
3236
static const Duration _animationDuration = Duration(milliseconds: 450);
3337

3438
void _addIcon(String icon) {
@@ -110,11 +114,51 @@ class _PictoTapScreenState extends State<PictoTapScreen> {
110114
as RenderRepaintBoundary?;
111115
if (boundary == null) return;
112116

113-
final image = await boundary.toImage(pixelRatio: 3);
114-
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
115-
if (byteData == null) return;
117+
const int out = _shareImageSize;
118+
final bSize = boundary.size;
119+
final pixelRatio = out / math.min(bSize.width, bSize.height);
120+
final rawImage = await boundary.toImage(pixelRatio: pixelRatio);
116121

117-
final pngBytes = byteData.buffer.asUint8List();
122+
final srcW = rawImage.width;
123+
final srcH = rawImage.height;
124+
final rawBytes = await rawImage.toByteData(
125+
format: ui.ImageByteFormat.rawStraightRgba,
126+
);
127+
if (rawBytes == null) return;
128+
final srcPixels = rawBytes.buffer.asUint8List();
129+
130+
final cropSize = math.min(srcW, srcH);
131+
final cropX = (srcW - cropSize) ~/ 2;
132+
final cropY = (srcH - cropSize) ~/ 2;
133+
134+
final outPixels = Uint8List(out * out * 4);
135+
for (var y = 0; y < out; y++) {
136+
final sy = (cropY + y * cropSize ~/ out).clamp(0, srcH - 1);
137+
for (var x = 0; x < out; x++) {
138+
final sx = (cropX + x * cropSize ~/ out).clamp(0, srcW - 1);
139+
final si = (sy * srcW + sx) * 4;
140+
final di = (y * out + x) * 4;
141+
outPixels[di] = srcPixels[si];
142+
outPixels[di + 1] = srcPixels[si + 1];
143+
outPixels[di + 2] = srcPixels[si + 2];
144+
outPixels[di + 3] = srcPixels[si + 3];
145+
}
146+
}
147+
148+
final completer = Completer<ui.Image>();
149+
ui.decodeImageFromPixels(
150+
outPixels,
151+
out,
152+
out,
153+
ui.PixelFormat.rgba8888,
154+
completer.complete,
155+
);
156+
final outputImage = await completer.future;
157+
final pngData =
158+
await outputImage.toByteData(format: ui.ImageByteFormat.png);
159+
if (pngData == null) return;
160+
161+
final pngBytes = pngData.buffer.asUint8List();
118162
final fileName =
119163
'pictotap-board-${DateTime.now().millisecondsSinceEpoch}.png';
120164
await saveAndShareImage(pngBytes, fileName, shareText);
@@ -223,30 +267,29 @@ class _PictoTapScreenState extends State<PictoTapScreen> {
223267
),
224268
foregroundColor: Colors.white,
225269
),
226-
body: Stack(
270+
body: Column(
227271
children: [
228-
Positioned.fill(
229-
child: Image.asset(
230-
_backgroundAssetPath,
231-
fit: BoxFit.cover,
232-
errorBuilder: (context, error, stackTrace) {
233-
return Container(color: const Color(0xFFF5F0EB));
234-
},
235-
),
236-
),
237-
Positioned.fill(
238-
child: Container(color: Colors.white.withAlpha(140)),
239-
),
240-
Column(
241-
children: [
242-
Expanded(
243-
child: GestureDetector(
244-
onTap: _toggleKeyboard,
245-
child: RepaintBoundary(
246-
key: _boardBoundaryKey,
247-
child: Container(
272+
Expanded(
273+
child: GestureDetector(
274+
onTap: _toggleKeyboard,
275+
child: RepaintBoundary(
276+
key: _boardBoundaryKey,
277+
child: Stack(
278+
children: [
279+
Positioned.fill(
280+
child: Image.asset(
281+
_backgroundAssetPath,
282+
fit: BoxFit.cover,
283+
errorBuilder: (context, error, stackTrace) {
284+
return Container(color: _backgroundFallback);
285+
},
286+
),
287+
),
288+
Positioned.fill(
289+
child: Container(color: Colors.white.withAlpha(140)),
290+
),
291+
SizedBox(
248292
width: double.infinity,
249-
color: Colors.transparent,
250293
child: _selectedIcons.isEmpty
251294
? Center(
252295
child: BoardEmptyHint(
@@ -267,19 +310,19 @@ class _PictoTapScreenState extends State<PictoTapScreen> {
267310
),
268311
),
269312
),
270-
),
313+
],
271314
),
272315
),
273-
if (_isKeyboardVisible)
274-
PictogramKeyboard(
275-
onIconSelected: _addIcon,
276-
onSpace: _addSpace,
277-
onBackspace: _removeLast,
278-
recommendations: buildRecommendations(_selectedIcons),
279-
showLimitBanner: _showLimitReachedBanner,
280-
),
281-
],
316+
),
282317
),
318+
if (_isKeyboardVisible)
319+
PictogramKeyboard(
320+
onIconSelected: _addIcon,
321+
onSpace: _addSpace,
322+
onBackspace: _removeLast,
323+
recommendations: buildRecommendations(_selectedIcons),
324+
showLimitBanner: _showLimitReachedBanner,
325+
),
283326
],
284327
),
285328
floatingActionButton: _isKeyboardVisible

test/data/pictogram_data_test.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:pictotap/data/pictogram_data.dart';
3+
4+
void main() {
5+
test('all icon lists use correct prefix and have no duplicates', () {
6+
final expected = {
7+
'descriptive:': descriptiveIcons,
8+
'people:': peopleIcons,
9+
'prepositions:': prepositionsIcons,
10+
'some:': someIcons,
11+
'substantive:': substantiveIcons,
12+
'verbs:': verbsIcons,
13+
};
14+
15+
final allIcons = <String>[];
16+
for (final entry in expected.entries) {
17+
expect(entry.value, isNotEmpty, reason: '${entry.key} list is empty');
18+
for (final icon in entry.value) {
19+
expect(icon, startsWith(entry.key));
20+
}
21+
allIcons.addAll(entry.value);
22+
}
23+
24+
expect(allIcons.toSet().length, allIcons.length,
25+
reason: 'Found duplicate icons');
26+
});
27+
28+
test('keyboardCategories maps all icon lists', () {
29+
expect(keyboardCategories.length, 6);
30+
expect(keyboardCategories[0].icons, descriptiveIcons);
31+
expect(keyboardCategories[5].icons, verbsIcons);
32+
});
33+
}

test/helpers/pump_app.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:pictotap/l10n/app_localizations.dart';
4+
5+
extension PumpApp on WidgetTester {
6+
Future<void> pumpApp(Widget widget) async {
7+
await pumpWidget(
8+
MaterialApp(
9+
localizationsDelegates: AppLocalizations.localizationsDelegates,
10+
supportedLocales: AppLocalizations.supportedLocales,
11+
locale: const Locale('en'),
12+
home: Scaffold(body: widget),
13+
),
14+
);
15+
await pump();
16+
}
17+
}

test/image_saver_stub_test.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:pictotap/image_saver_stub.dart';
5+
6+
void main() {
7+
test('saveAndShareImage throws UnsupportedError', () {
8+
expect(
9+
() => saveAndShareImage(Uint8List(0), 'test.png', 'text'),
10+
throwsA(isA<UnsupportedError>()),
11+
);
12+
});
13+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:pictotap/data/pictogram_data.dart';
4+
import 'package:pictotap/l10n/app_localizations.dart';
5+
import 'package:pictotap/screens/pictotap_screen.dart';
6+
import 'package:pictotap/widgets/board_empty_hint.dart';
7+
import 'package:pictotap/widgets/pictogram_keyboard.dart';
8+
9+
void main() {
10+
Widget buildApp() {
11+
return const MaterialApp(
12+
localizationsDelegates: AppLocalizations.localizationsDelegates,
13+
supportedLocales: AppLocalizations.supportedLocales,
14+
locale: Locale('en'),
15+
home: PictoTapScreen(),
16+
);
17+
}
18+
19+
group('PictoTapScreen', () {
20+
testWidgets('initial state: hint visible, keyboard hidden, FAB visible',
21+
(tester) async {
22+
await tester.pumpWidget(buildApp());
23+
await tester.pump();
24+
25+
expect(find.text('PictoTap'), findsOneWidget);
26+
expect(find.byType(BoardEmptyHint), findsOneWidget);
27+
expect(find.byType(PictogramKeyboard), findsNothing);
28+
expect(find.byType(FloatingActionButton), findsOneWidget);
29+
});
30+
31+
testWidgets('tapping board toggles keyboard and hides FAB', (tester) async {
32+
await tester.pumpWidget(buildApp());
33+
await tester.pump();
34+
35+
await tester.tap(find.byType(GestureDetector).first);
36+
await tester.pump();
37+
expect(find.byType(PictogramKeyboard), findsOneWidget);
38+
await tester.pump(const Duration(milliseconds: 500));
39+
expect(find.byType(FloatingActionButton), findsNothing);
40+
41+
await tester.tap(find.byType(GestureDetector).first);
42+
await tester.pump();
43+
expect(find.byType(PictogramKeyboard), findsNothing);
44+
});
45+
46+
testWidgets('add icon, then backspace removes it', (tester) async {
47+
await tester.pumpWidget(buildApp());
48+
await tester.pump();
49+
50+
await tester.tap(find.byType(GestureDetector).first);
51+
await tester.pump();
52+
53+
final firstIcon = keyboardCategories.first.icons.first;
54+
await tester.tap(find.bySemanticsLabel(firstIcon.split(':').last).first);
55+
await tester.pump(const Duration(milliseconds: 500));
56+
expect(find.byType(BoardEmptyHint), findsNothing);
57+
58+
await tester.tap(find.byIcon(Icons.backspace_outlined));
59+
await tester.pump(const Duration(milliseconds: 500));
60+
expect(find.byType(BoardEmptyHint), findsOneWidget);
61+
});
62+
63+
testWidgets('limit banner appears at max icons', (tester) async {
64+
await tester.pumpWidget(buildApp());
65+
await tester.pump();
66+
67+
await tester.tap(find.byType(GestureDetector).first);
68+
await tester.pump();
69+
70+
for (int i = 0; i < defaultBoardMaxIcons; i++) {
71+
await tester.tap(find.bySemanticsLabel('Add space'));
72+
await tester.pumpAndSettle();
73+
}
74+
75+
expect(find.text('Limit reached'), findsOneWidget);
76+
});
77+
});
78+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:pictotap/data/pictogram_data.dart';
3+
import 'package:pictotap/utils/pictogram_utils.dart';
4+
5+
void main() {
6+
group('displayNameForIcon', () {
7+
test('returns space for spaceIcon', () {
8+
expect(displayNameForIcon(spaceIcon), ' ');
9+
});
10+
11+
test('strips known prefix and returns name', () {
12+
expect(displayNameForIcon('descriptive:feliz'), 'feliz');
13+
expect(displayNameForIcon('verbs:comer'), 'comer');
14+
});
15+
16+
test('returns unchanged for unknown prefix', () {
17+
expect(displayNameForIcon('emoji_smile'), 'emoji_smile');
18+
});
19+
});
20+
21+
group('isAssetIcon', () {
22+
test('recognises known prefixes', () {
23+
expect(isAssetIcon('people:papa'), true);
24+
});
25+
26+
test('rejects plain text and spaceIcon', () {
27+
expect(isAssetIcon('hello'), false);
28+
expect(isAssetIcon(spaceIcon), false);
29+
});
30+
});
31+
32+
group('assetPathForIcon', () {
33+
test('builds correct asset path', () {
34+
expect(
35+
assetPathForIcon('substantive:agua'),
36+
'assets/keyboard/substantive/agua.png',
37+
);
38+
});
39+
40+
test('returns null for non-asset icon', () {
41+
expect(assetPathForIcon(spaceIcon), isNull);
42+
});
43+
});
44+
45+
group('buildRecommendations', () {
46+
test('empty input returns empty list', () {
47+
expect(buildRecommendations([]), isEmpty);
48+
});
49+
50+
test('deduplicates and returns most recent first', () {
51+
final result = buildRecommendations([
52+
'people:mama',
53+
'verbs:comer',
54+
'people:mama',
55+
]);
56+
expect(result, ['people:mama', 'verbs:comer']);
57+
});
58+
59+
test('caps at maxRecommendationIcons', () {
60+
final icons = List.generate(20, (i) => 'verbs:icon$i');
61+
expect(buildRecommendations(icons).length, maxRecommendationIcons);
62+
});
63+
});
64+
}

test/widget_test.dart

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)