Flutter: 第1フェーズ総合演習 — ShopAppスターター版
練習なしの学習は、食材を買って料理しないようなものです — このレッスンでは最初の6レッスンの知識をすべて完全なShopAppの宴に変えます。
📋 前提条件: 以下を先に完了している必要があります
- レッスン1:Flutter入門と環境構築
- レッスン2:Dart言語速習
- レッスン3:Widgetの基礎
- レッスン4:レイアウトシステム
- レッスン5:StatefulWidgetとインタラクション
- レッスン6:Material Designと共通コンポーネント
1. このレッスンで学ぶこと
- プロジェクト構造の計画:lib/配下のmodels / widgets / screensのレイヤー化
- 商品データモデル:id / name / price(USD) / imageUrl / rating
- ホームページのGridViewで1000+の商品カードを表示(モックデータ)
- カートページ:StatefulWidgetによる商品数量と合計金額の管理
- 商品詳細ページ:SliverAppBar + サイズ選択BottomSheet
2. ゼロからイチへのリアルなストーリー
(1) 悩み:断片化された知識
Bobは最初の6レッスンを修了し — Widgetの記述、レイアウトの作成、インタラクションの処理ができます。しかし、この知識を完全なアプリケーションに組み立てる時、どこから始めればよいか分かりません。ファイルの整理方法は?データの渡し方は?ページ間のナビゲーションは?パズルのピースを持っているのに絵が完成しないようなものでした。
(2) プロジェクトベースのソリューション
ShopAppスターター版を構築することで、断片化された知識が完全なチェーンに繋がります:データモデル → リスト表示 → インタラクション → ページナビゲーション → 状態管理。
(3) 結果:「コンポーネントが書ける」から「プロジェクトが構築できる」へ
ShopAppスターター版を完成させた後、Bobは独立して完全なFlutterアプリケーションを構築する能力を獲得しました。第2フェーズでは状態管理とネットワーク層を置き換えるだけで済みます。
3. プロジェクト構造の計画
(1) ディレクトリ構造
TEXT
shop_app/
├── lib/
│ ├── main.dart # App entry point
│ ├── models/
│ │ └── product.dart # Product data model
│ ├── widgets/
│ │ ├── product_card.dart # Reusable product card
│ │ └── quantity_selector.dart
│ ├── screens/
│ │ ├── home_page.dart # Product grid
│ │ ├── detail_page.dart # Product detail
│ │ └── cart_page.dart # Shopping cart
│ └── data/
│ └── mock_products.dart # Mock data generator
├── pubspec.yaml
└── test/
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(2) レイヤー化の原則
| レイヤー | 責務 | 依存方向 |
|---|---|---|
| models | 純粋なデータ定義、UIなし | 依存なし |
| widgets | 再利用可能なUIコンポーネント | modelsに依存 |
| screens | ページレベルのWidget | models + widgetsに依存 |
| data | データプロバイダー(モック/API) | modelsに依存 |
| main | アプリのエントリ + ルーティング | screensに依存 |
4. データモデルとモックデータ
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 商品データモデル
DART
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final double rating;
final int reviewCount;
final String category;
const Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
this.rating = 0.0,
this.reviewCount = 0,
this.category = 'General',
});
}
このProductクラスは、このレッスンのすべてのコードブロックで使用されます。
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: モックデータジェネレーター
DART
// Product class definition shown above
class MockProducts {
static final List<String> _names = [
'Pro Laptop 16"', 'Wireless Earbuds', 'Smart Watch Pro',
'Cotton T-Shirt', 'Running Shoes', 'Backpack Ultra',
'Coffee Maker', 'Desk Lamp LED', 'Yoga Mat Premium',
'Novel Collection', 'Wireless Mouse', 'USB-C Hub',
];
static final List<String> _categories = [
'Electronics', 'Clothing', 'Home', 'Books', 'Sports',
];
static List<Product> generate({int count = 1000}) {
return List.generate(count, (i) => Product(
id: i + 1,
name: _names[i % _names.length],
price: (9.99 + (i % 50) * 10).toDouble(),
imageUrl: 'https://cdn.shopapp.com/products/item_${i + 1}.jpg',
rating: (3.0 + (i % 20) * 0.1),
reviewCount: (i % 5) * 100,
category: _categories[i % _categories.length],
));
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. ホームページ:商品グリッド
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: HomePageの実装
DART
import 'package:flutter/material.dart';
// Product class definition shown above
// Simplified class definition
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
CartItem copyWith({Product? product, int? quantity}) =>
CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final List<Product> _products = MockProducts.generate(count: 1000);
String _selectedCategory = 'All';
List<Product> get _filtered => _selectedCategory == 'All'
? _products
: _products.where((p) => p.category == _selectedCategory).toList();
@override
Widget build(BuildContext context) {
final categories = ['All', ...MockProducts._categories.toSet()];
return Scaffold(
appBar: AppBar(
title: const Text('ShopApp'),
actions: [
Stack(
alignment: Alignment.center,
children: [
IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () => _navigateToCart()),
if (_cartItems.isNotEmpty)
Positioned(right: 4, top: 4,
child: CircleAvatar(radius: 8, backgroundColor: Colors.red,
child: Text('${_cartItems.length}', style: const TextStyle(fontSize: 9, color: Colors.white)))),
],
),
],
),
body: Column(
children: [
// Category filter chips
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: categories.map((cat) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(cat),
selected: cat == _selectedCategory,
onSelected: (_) => setState(() => _selectedCategory = cat),
),
)).toList(),
),
),
// Product grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount: _filtered.length,
itemBuilder: (context, index) => ProductCard(
key: ValueKey(_filtered[index].id),
product: _filtered[index],
onAddToCart: () => _addToCart(_filtered[index]),
onTap: () => _navigateToDetail(_filtered[index]),
),
),
),
],
),
);
}
final List<CartItem> _cartItems = [];
void _addToCart(Product product) {
setState(() {
final idx = _cartItems.indexWhere((c) => c.product.id == product.id);
if (idx >= 0) {
_cartItems[idx] = _cartItems[idx].copyWith(quantity: _cartItems[idx].quantity + 1);
} else {
_cartItems.add(CartItem(product: product, quantity: 1));
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart'), duration: const Duration(seconds: 1)),
);
}
void _navigateToCart() {
Navigator.push(context, MaterialPageRoute(
builder: (_) => CartPage(items: _cartItems, onUpdate: (items) => setState(() => _cartItems.clear()..addAll(items))),
));
}
void _navigateToDetail(Product product) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => DetailPage(product: product, onAddToCart: () => _addToCart(product)),
));
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. カートページ
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: CartPageの実装
DART
import 'package:flutter/material.dart';
// Product class definition shown above
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
double get total => product.price * quantity;
CartItem copyWith({Product? product, int? quantity}) =>
CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}
class CartPage extends StatefulWidget {
final List<CartItem> items;
final ValueChanged<List<CartItem>> onUpdate;
const CartPage({super.key, required this.items, required this.onUpdate});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
late List<CartItem> _items;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
double get _total => _items.fold(0.0, (sum, item) => sum + item.total);
void _updateQty(int index, int qty) {
setState(() {
if (qty <= 0) _items.removeAt(index);
else _items[index] = _items[index].copyWith(quantity: qty);
});
widget.onUpdate(_items);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart (${_items.length})')),
body: _items.isEmpty
? const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Your cart is empty', style: TextStyle(fontSize: 18, color: Colors.grey)),
]))
: Column(children: [
Expanded(child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, i) {
final item = _items[i];
return ListTile(
leading: Image.network(item.product.imageUrl, width: 50, height: 50, fit: BoxFit.cover),
title: Text(item.product.name),
subtitle: Text('$${item.total.toStringAsFixed(2)}'),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(icon: const Icon(Icons.remove), onPressed: () => _updateQty(i, item.quantity - 1)),
Text('${item.quantity}'),
IconButton(icon: const Icon(Icons.add), onPressed: () => _updateQty(i, item.quantity + 1)),
]),
);
},
)),
// Total bar
Container(padding: const EdgeInsets.all(16), color: Colors.grey[100],
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Total: $${_total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
FilledButton(onPressed: () {}, child: const Text('Checkout')),
])),
]),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
7. 商品詳細ページ
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: DetailPageの実装
DART
import 'package:flutter/material.dart';
// Product class definition shown above
class DetailPage extends StatelessWidget {
final Product product;
final VoidCallback onAddToCart;
const DetailPage({super.key, required this.product, required this.onAddToCart});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(slivers: [
SliverAppBar(expandedHeight: 300, pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(product.name, style: const TextStyle(fontSize: 14, shadows: [Shadow(color: Colors.black54)])),
background: Image.network(product.imageUrl, fit: BoxFit.cover),
),
),
SliverToBoxAdapter(child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.category, style: TextStyle(color: Colors.grey[600])),
const SizedBox(height: 4),
Row(children: [
Text('$${product.price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)),
const SizedBox(width: 8),
Icon(Icons.star, color: Colors.amber[700], size: 16),
Text(' ${product.rating} (${product.reviewCount})'),
]),
const SizedBox(height: 16),
const Text('Description', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Premium quality product. Free shipping on orders over $50. USD settlement supported.'),
],
))),
]),
bottomNavigationBar: SafeArea(child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
Expanded(child: FilledButton.icon(onPressed: onAddToCart,
icon: const Icon(Icons.shopping_cart), label: const Text('Add to Cart - $${product.price}'))),
]))),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
8. 完成例:ShopAppスターター版 main.dart
DART
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'data/mock_products.dart';
import 'screens/home_page.dart';
void main() => runApp(const ShopAppStarter);
const ShopAppStarter = ShopApp();
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ShopApp Starter',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
),
home: const HomePage(),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q プロジェクトファイルが多くてimportパスを間違えやすいですが?
A VS Codeの相対パス自動補完を使用してください。規約:models/は
model.dartで終わる、widgets/はコンポーネント名にする。Q setStateで別のページに渡したカートデータが、戻ると失われるのは?
A 現在はコールバック関数を使用しています。第2フェーズでRiverpodグローバル状態管理で解決します。
Q 1000アイテムのGridViewはカクつきますか?
A GridView.builderは表示中のアイテムのみを遅延読み込みするため — 1000アイテムでも問題ありません。各アイテムが複雑な場合は、itemExtent最適化を検討してください。
Q モックデータの画像が読み込まれないのは?
A 例ではプレースホルダーURLを使用しています。実際の開発では
placeholder: AssetImage()やIconsに置き換えてください。Q Navigator.pushナビゲーションでパラメータを渡し、戻り値を受け取るには?
A ナビゲーション時にコンストラクタで渡し、
Navigator.pop(context, result)で返し、awaitで受け取ります。Q 第1フェーズのコードは第2フェーズに直接移行できますか?
A はい。データモデルとWidgetレイヤーはほぼ変更なし — setStateをRiverpodプロバイダーに置き換えるだけです。
📖 まとめ
- プロジェクトのレイヤー化:models(データ)/ widgets(コンポーネント)/ screens(ページ)/ data(データソース)
- モックデータで素早いUI検証、後で実際のAPIに置き換え
- GridView.builderの遅延読み込みが千件レベルの商品リストに対応
- カートはStatefulWidget + setStateで管理、データはコールバックで渡す
- SliverAppBarが没入型の詳細ページ体験を作成
📝 練習問題
- 基本(難易度 ⭐): チュートリアルの構造に従ってプロジェクトを作成し、HomePageに20の商品カードが表示されることを確認してください。
- 中級(難易度 ⭐⭐): HomePageに検索機能を追加してください:キーワードを入力して商品名をフィルタリングし、setStateでリストを更新。
- チャレンジ(難易度 ⭐⭐⭐): 完全な3ページナビゲーションフローを実装してください:ホーム → 詳細 → カート、カートで数量を変更して戻った後、AppBarのバッジ番号が自動更新されるようにする。