Flutter: 第1フェーズ総合演習 — ShopAppスターター版

練習なしの学習は、食材を買って料理しないようなものです — このレッスンでは最初の6レッスンの知識をすべて完全なShopAppの宴に変えます。

📋 前提条件: 以下を先に完了している必要があります

1. このレッスンで学ぶこと


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プロバイダーに置き換えるだけです。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐): チュートリアルの構造に従ってプロジェクトを作成し、HomePageに20の商品カードが表示されることを確認してください。
  2. 中級(難易度 ⭐⭐): HomePageに検索機能を追加してください:キーワードを入力して商品名をフィルタリングし、setStateでリストを更新。
  3. チャレンジ(難易度 ⭐⭐⭐): 完全な3ページナビゲーションフローを実装してください:ホーム → 詳細 → カート、カートで数量を変更して戻った後、AppBarのバッジ番号が自動更新されるようにする。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%