Instruction file imported from yashnandwanii/Yash-Assignment-CRED (
.github/instructions/todo.instructions.md). Copyright stays with the author.
Below is a concise, actionable step‑by‑step roadmap + minimal code scaffolding (GetX + Dio only) to build the requested robust Flutter app using the provided JSON. Follow tasks in order, use the file templates, run tests described, keep micro commits, and produce the deliverables (zip, apk, videos).
- High-level choices (brief)
- State, routing, DI, reactive UI → GetX (GetMaterialApp, GetxController, Bindings, Obx).
- Networking → Dio.
- No other 3rd‑party packages (no provider/get_storage/etc) as requested.
- Performance: PageView vertical + transforms for stacked cards; RepaintBoundary, precache images, keep widgets const, minimize rebuilds.
- Tests: unit tests (parsing), widget tests (2-items vs >2 UI), integration test to collect FrameTiming to detect frame drops.
- Folder structure
- lib/
- main.dart
- app/
- routes/
- app_pages.dart
- app_routes.dart
- bindings/
- app_binding.dart
- routes/
- data/
- models/
- section_model.dart
- card_model.dart
- services/
- api_service.dart
- models/
- modules/
- bills/
- controllers/
- bills_controller.dart
- views/
- bills_page.dart
- widgets/
- vertical_carousel.dart
- bill_card.dart
- controllers/
- bills/
- test/
- api_service_test.dart
- widget/bills_widget_test.dart
- integration/frame_timing_test.dart
- Step-by-step implementation tasks (ordered)
-
Task A (setup) — 30m
- Create project. Add dependencies (get, dio).
- Init git repo & .gitignore. Make initial commit.
-
Task B (models + parsing) — 1.5h
- Implement models mapping the JSON (Section → child_list → cards).
- Unit test for ApiService->models parsing.
-
Task C (ApiService: Dio) — 1h
- Implement GET that returns Section model. Add simple retries/backoff.
-
Task D (GetX Controller + Bindings) — 1.5h
- BillsController: RxList, loading RxBool, fetch method, pre-cache logos (use precacheImage after first frame).
- AppBinding to lazyPut controller & service.
-
Task E (UI: vertical carousel & card + flip tag) — 2.5–4h
- Vertical PageView (Axis.vertical) with viewportFraction < 1 and AnimatedBuilder to transform/scale stack.
- BillCard widget with right-aligned CTA and bottom tag that flips: implement flip with AnimatedSwitcher + Timer (use RxString/obs for currentTag).
- Wrap each card with RepaintBoundary and const where possible.
-
Task F (Performance & prefetch) — 1h
- Precache network logos in controller after fetch (schedule in WidgetsBinding.instance.addPostFrameCallback).
- Avoid rebuilding whole list: ensure Obx only wraps small subtrees.
-
Task G (Tests) — 3–4h
- Unit test for parsing (api_service_test.dart).
- Widget test: pump BillsPage twice (with 2 items and 9 items) and assert widget tree states.
- Integration test for frame timings: collect FrameTiming with SchedulerBinding.addTimingsCallback while performing a vertical drag; fail if percentile > threshold.
-
Task H (Polish + deliverables) — 2h
- README, record videos of flows, build apk, zip project (include .git). Remove build artifacts as requested, but keep .git for history.
- Key performance testing approach (frame drops)
- Automated integration test will:
- Add SchedulerBinding.instance.addTimingsCallback to collect FrameTiming objects.
- Simulate repeated fast vertical drags (WidgetTester or integration_test).
- After interactions, compute worst-case raster/builder/frame build times and assert worstFrameDurationMillis < 20ms (tweak per device).
- Also manual profiling: flutter run --profile, open DevTools → Performance.
- Minimal code scaffolding (paste into files). Use these as starting point.
pubspec.yaml (dependencies)
// ...existing code...
name: cred_assignment
description: Vertical swipeable carousel using GetX + Dio
version: 0.0.1
environment:
sdk: ">=2.18.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
get: ^4.6.5
dio: ^5.0.0
dev_dependencies:
flutter_test:
sdk: flutter
# ...existing code...
API service (Dio)
// ...existing code...
import 'package:dio/dio.dart';
import '../models/section_model.dart';
class ApiService {
final Dio _dio;
ApiService([Dio? dio]) : _dio = dio ?? Dio();
Future<SectionModel> fetchSection(String url) async {
try {
final res = await _dio.get(url);
if (res.statusCode == 200) {
return SectionModel.fromJson(res.data as Map<String, dynamic>);
} else {
throw Exception('Status code: ${res.statusCode}');
}
} on DioError catch (e) {
throw Exception('Network error: ${e.message}');
}
}
}
// ...existing code...
Models (section + card) — essential fields only
// ...existing code...
class SectionModel {
final String title;
final List<CardModel> cards;
SectionModel({required this.title, required this.cards});
factory SectionModel.fromJson(Map<String, dynamic> json) {
final body = json['template_properties']?['body'] ?? {};
final children = json['template_properties']?['child_list'] as List<dynamic>? ?? [];
return SectionModel(
title: body['title'] ?? '',
cards: children.map((e) => CardModel.fromJson(e as Map<String, dynamic>)).toList(),
);
}
}
class CardModel {
final String externalId;
final String title;
final String subtitle;
final String paymentAmount;
final String footerText; // may be null
final List<String> flipperItems; // if empty → no flipper
CardModel({
required this.externalId,
required this.title,
required this.subtitle,
required this.paymentAmount,
required this.footerText,
required this.flipperItems,
});
factory CardModel.fromJson(Map<String, dynamic> json) {
final props = json['template_properties'] ?? {};
final body = props['body'] ?? {};
final flipper = body['flipper_config'];
List<String> items = [];
if (flipper != null && flipper['items'] is List) {
items = (flipper['items'] as List).map((it) => it['text']?.toString() ?? '').toList();
}
return CardModel(
externalId: json['external_id'] ?? '',
title: body['title'] ?? '',
subtitle: body['sub_title'] ?? '',
paymentAmount: body['payment_amount'] ?? '',
footerText: body['footer_text'] ?? '',
flipperItems: items,
);
}
}
// ...existing code...
GetX Controller + pre-cache images + flipping management
// ...existing code...
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import '../../data/models/section_model.dart';
import '../../data/services/api_service.dart';
class BillsController extends GetxController {
final ApiService api;
final String url;
BillsController({required this.api, required this.url});
var title = ''.obs;
var cards = <CardModel>[].obs;
var loading = false.obs;
var error = RxnString();
// map externalId -> current flipper index
final Map<String, RxInt> _flipperIndex = {};
BillsController._(this.api, this.url);
@override
void onInit() {
super.onInit();
fetch();
}
Future<void> fetch() async {
loading.value = true;
error.value = null;
try {
final SectionModel section = await api.fetchSection(url);
title.value = section.title;
cards.assignAll(section.cards);
// initialize flipper indices
for (var c in cards) {
if (c.flipperItems.isNotEmpty) {
_flipperIndex[c.externalId] = 0.obs;
// start periodic flip for this card
_startFlipperTimer(c);
}
}
// schedule image precache after first frame (if any network logos)
WidgetsBinding.instance.addPostFrameCallback((_) {
_precacheImages();
});
} catch (e) {
error.value = e.toString();
} finally {
loading.value = false;
}
}
RxInt? flipperIndexOf(String externalId) => _flipperIndex[externalId];
void _startFlipperTimer(CardModel card) {
if (card.flipperItems.isEmpty) return;
// simple repeating flip every 2s (use real config values if available)
Timer.periodic(const Duration(milliseconds: 2000), (t) {
if (!cards.any((c) => c.externalId == card.externalId)) {
t.cancel();
return;
}
final idx = _flipperIndex[card.externalId]!;
idx.value = (idx.value + 1) % card.flipperItems.length;
});
}
Future<void> _precacheImages() async {
try {
for (final c in cards) {
// if logos exist you can precache with:
// final url = ... extract logo URL from source JSON if added in model
// final image = NetworkImage(url); await precacheImage(image, Get.context!);
}
} catch (_) {}
}
}
// ...existing code...
Binding + main + routing
// ...existing code...
import 'package:get/get.dart';
import '../../data/services/api_service.dart';
import '../../modules/bills/controllers/bills_controller.dart';
class AppBinding extends Bindings {
@override
void dependencies() {
Get.put(ApiService());
// NOTE: provide the mock URL in binding or when navigating
Get.lazyPut<BillsController>(() => BillsController(api: Get.find(), url: 'http://jsonblob.com/1425066643679272960'));
}
}
// ...existing code...
// ...existing code...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'app/bindings/app_binding.dart';
import 'modules/bills/views/bills_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
initialBinding: AppBinding(),
home: const BillsPage(),
debugShowCheckedModeBanner: false,
);
}
}
// ...existing code...
Vertical carousel + card (key parts)
// ...existing code...
import 'package:flutter/material.dart';
import 'bill_card.dart';
class VerticalCarousel extends StatefulWidget {
final List<dynamic> items; // CardModel list
const VerticalCarousel({super.key, required this.items});
@override
State<VerticalCarousel> createState() => _VerticalCarouselState();
}
class _VerticalCarouselState extends State<VerticalCarousel> {
late PageController _pc;
@override
void initState() {
super.initState();
_pc = PageController(viewportFraction: 0.85);
}
@override
void dispose() {
_pc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 220,
child: PageView.builder(
controller: _pc,
scrollDirection: Axis.vertical,
itemCount: widget.items.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _pc,
builder: (ctx, child) {
double value = 0;
try {
value = (_pc.page ?? _pc.initialPage) - index;
} catch (_) {
value = 0;
}
value = (value).clamp(-1.0, 1.0);
final scale = 1 - (value.abs() * 0.08);
final translateY = value * 20;
return Transform.translate(
offset: Offset(0, translateY),
child: Transform.scale(scale: scale, child: child),
);
},
child: RepaintBoundary(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: BillCard(card: widget.items[index]),
),
),
);
},
),
);
}
}
// ...existing code...
BillCard with AnimatedSwitcher for flip tag
// ...existing code...
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/bills_controller.dart';
import '../../data/models/section_model.dart'; // or card_model
class BillCard extends StatelessWidget {
final CardModel card;
const BillCard({super.key, required this.card});
@override
Widget build(BuildContext context) {
final controller = Get.find<BillsController>();
final flipperIdx = controller.flipperIndexOf(card.externalId);
return Material(
elevation: 3,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// logo placeholder
CircleAvatar(child: Text(card.title.isNotEmpty ? card.title[0] : '?')),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(card.title, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(card.subtitle, style: const TextStyle(color: Colors.grey)),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ElevatedButton(onPressed: () {}, style: ElevatedButton.styleFrom(backgroundColor: Colors.black), child: Text(card.paymentAmount)),
const SizedBox(height: 6),
SizedBox(
height: 20,
child: card.flipperItems.isEmpty
? Text(card.footerText, style: const TextStyle(color: Colors.red))
: Obx(() {
final idx = flipperIdx?.value ?? 0;
final txt = card.flipperItems[idx];
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (w, a) => FadeTransition(opacity: a, child: w),
child: Text(txt, key: ValueKey(txt), style: const TextStyle(color: Colors.red)),
);
}),
),
],
),
],
),
),
);
}
}
// ...existing code...
- Tests (high level)
- Unit: test/api_service_test.dart — mock Dio with Response(data) and check SectionModel parsing.
- Widget: test/widget/bills_widget_test.dart — pump BillsPage with a mock controller (use Get.testMode, override dependency), verify when controller.cards length == 2 the layout renders exactly two visible cards; when >2 stack and viewportFraction shows stacked look.
- Integration test (frame timing):
- In integration test, collect frame timings with SchedulerBinding.instance.addTimingsCallback.
- Simulate repeated fast drags: tester.drag(find.byType(PageView), const Offset(0, -300), warnIfMissed: false); pumpAndSettle.
- Compute frame durations: search FrameTiming.totalSpan or raster/build cost and assert < threshold (e.g., 20ms). Fail test if frames exceed threshold.
- README content (must include assumptions & AI usage)
- Explain architecture choices (GetX + Dio), how to run, tests covered, assumptions (which JSON fields used, flipper timing default), AI usage statement: “AI used to scaffold plan and code snippets — all final code reviewed & adjusted.”
- Git & deliverables checklist
- Use small commits (one feature/file per commit).
- Do NOT push to GitHub — keep repository local.
- Remove generated build dirs before zipping: build/, .dart_tool/, .idea/, .gradle/.
- Create APK: flutter build apk --release
- Record videos of flows (2‑3 short recordings): 2 items view, multi-items scroll, flip tag demo.
- Zip repo and share via Drive.
- Important implementation notes & tips (short)
- Precache logos after fetch using WidgetsBinding.instance.addPostFrameCallback + precacheImage to avoid jank from first paint.
- Use RepaintBoundary on card subtrees to reduce repaint cost.
- Keep animation math on GPU (Transforms)—avoid heavy layout work inside animation.
- Avoid expensive setState; use Obx for minimal reactive updates.
- For device-accurate frame timing tests, run integration_test in profile mode on device.
- Assumptions (explicit)
- Use only fields from JSON shown (title, sub_title, payment_amount, footer_text, flipper_config.items).
- Flipper delay defaulted to 2000ms if not present.
- No persistent storage required.
- Target 60fps; test threshold set to ~20ms per frame for acceptable margin.
- AI usage
- This plan and code scaffolding were generated with AI assistance and then adapted to the assignment requirements. Mark this explicitly in README.
If you want I can:
- Generate the complete file contents for every file shown above (ready to paste) — say “create files”.
- Provide example integration_test code that collects FrameTiming (I can produce the full test file).
- Produce a checklist of micro‑commit messages to use.
Which next step do you want: full file outputs, tests files, or the integration test code?