🏠 Home / Hub

📱 Projects 04 — Flutter Mobile Client

← Projects Menu

Goal: Laravel API (Project 03) ကို Dart/Flutter mobile app နဲ့ consume လုပ်မယ်။ Login, product list, product detail, add/edit screens ပါမယ်။ Android နဲ့ iOS တစ်ပြိုင်နက် run ဖြစ်မယ်။

1. Setup & Dependencies

flutter create product_mobile
cd product_mobile

# pubspec.yaml dependencies:
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0                # API calls
  flutter_secure_storage: ^9.0.0  # store token securely
  provider: ^6.1.2            # state management
  cached_network_image: ^3.3.1  # image caching
  go_router: ^13.0.0          # navigation

flutter pub get

2. Project Structure

lib/
├── main.dart
├── core/
│   ├── api_client.dart       ← base HTTP client
│   ├── storage.dart          ← token storage
│   └── constants.dart        ← API base URL
├── models/
│   ├── product.dart
│   └── user.dart
├── services/
│   ├── auth_service.dart
│   └── product_service.dart
├── providers/
│   ├── auth_provider.dart
│   └── product_provider.dart
├── screens/
│   ├── login_screen.dart
│   ├── product_list_screen.dart
│   ├── product_detail_screen.dart
│   └── product_form_screen.dart
└── widgets/
    ├── product_card.dart
    └── loading_indicator.dart

3. API Client (core/api_client.dart)

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'storage.dart';
import 'constants.dart';

class ApiClient {
  static Future<Map<String, String>> _headers() async {
    final token = await Storage.getToken();
    return {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      if (token != null) 'Authorization': 'Bearer $token',
    };
  }

  static Future<Map<String, dynamic>> get(String path) async {
    final res = await http.get(
      Uri.parse('${Constants.apiUrl}$path'),
      headers: await _headers(),
    );
    return _handle(res);
  }

  static Future<Map<String, dynamic>> post(String path, Map body) async {
    final res = await http.post(
      Uri.parse('${Constants.apiUrl}$path'),
      headers: await _headers(),
      body: jsonEncode(body),
    );
    return _handle(res);
  }

  static Future<Map<String, dynamic>> put(String path, Map body) async {
    final res = await http.put(
      Uri.parse('${Constants.apiUrl}$path'),
      headers: await _headers(),
      body: jsonEncode(body),
    );
    return _handle(res);
  }

  static Future<void> delete(String path) async {
    await http.delete(
      Uri.parse('${Constants.apiUrl}$path'),
      headers: await _headers(),
    );
  }

  static Map<String, dynamic> _handle(http.Response res) {
    final data = jsonDecode(res.body);
    if (res.statusCode >= 400) {
      throw Exception(data['message'] ?? 'Request failed');
    }
    return data;
  }
}

4. Product Model

// models/product.dart
class Product {
  final int    id;
  final String name;
  final String? description;
  final double price;
  final int    stock;
  final String? imageUrl;

  Product({
    required this.id,
    required this.name,
    this.description,
    required this.price,
    required this.stock,
    this.imageUrl,
  });

  factory Product.fromJson(Map<String, dynamic> json) {
    return Product(
      id:          json['id'],
      name:        json['name'],
      description: json['description'],
      price:       (json['price'] as num).toDouble(),
      stock:       json['stock'] ?? 0,
      imageUrl:    json['image_url'],
    );
  }

  Map<String, dynamic> toJson() => {
    'name':        name,
    'description': description,
    'price':       price,
    'stock':       stock,
  };
}

5. Product List Screen

// screens/product_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';
import '../widgets/product_card.dart';

class ProductListScreen extends StatefulWidget {
  @override _ProductListScreenState createState() => _ProductListScreenState();
}

class _ProductListScreenState extends State<ProductListScreen> {
  final _search = TextEditingController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<ProductProvider>().fetchProducts();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Products'),
        actions: [
          IconButton(icon: Icon(Icons.add), onPressed: () {
            Navigator.pushNamed(context, '/products/new').then(
              (_) => context.read<ProductProvider>().fetchProducts()
            );
          }),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: EdgeInsets.all(12),
            child: TextField(
              controller: _search,
              decoration: InputDecoration(
                hintText: 'Search products...',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
              ),
              onChanged: (v) => context.read<ProductProvider>().setSearch(v),
            ),
          ),
          Expanded(
            child: Consumer<ProductProvider>(
              builder: (_, p, __) {
                if (p.loading) return Center(child: CircularProgressIndicator());
                if (p.error != null) return Center(child: Text(p.error!, style: TextStyle(color: Colors.red)));
                if (p.products.isEmpty) return Center(child: Text('No products found'));
                return RefreshIndicator(
                  onRefresh: () => p.fetchProducts(),
                  child: ListView.builder(
                    itemCount: p.products.length,
                    itemBuilder: (_, i) => ProductCard(product: p.products[i]),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

6. Auth Login Screen

class LoginScreen extends StatefulWidget {
  @override _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _emailCtrl    = TextEditingController();
  final _passwordCtrl = TextEditingController();
  bool _loading = false, _obscure = true;

  Future<void> _login() async {
    setState(() => _loading = true);
    try {
      await context.read<AuthProvider>().login(
        _emailCtrl.text.trim(), _passwordCtrl.text,
      );
      context.go('/products');
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(e.toString()), backgroundColor: Colors.red),
      );
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(child: Padding(
        padding: EdgeInsets.all(24),
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          Text('Product App', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
          SizedBox(height: 40),
          TextField(controller: _emailCtrl,
            decoration: InputDecoration(labelText: 'Email', prefixIcon: Icon(Icons.email)),
            keyboardType: TextInputType.emailAddress),
          SizedBox(height: 16),
          TextField(controller: _passwordCtrl, obscureText: _obscure,
            decoration: InputDecoration(labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
              suffixIcon: IconButton(icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
                onPressed: () => setState(() => _obscure = !_obscure))),
          ),
          SizedBox(height: 28),
          SizedBox(width: double.infinity, child: ElevatedButton(
            onPressed: _loading ? null : _login,
            child: _loading ? CircularProgressIndicator(color: Colors.white) : Text('Login'),
          )),
        ]),
      )),
    );
  }
}

7. Key Concepts Summary

FeatureFlutter Approach
HTTP callshttp package + custom ApiClient wrapper
Token storageflutter_secure_storage (encrypted)
State managementProvider + ChangeNotifier
Navigationgo_router (named routes, guards)
Loading stateCircularProgressIndicator + Consumer
Pull to refreshRefreshIndicator widget
Image loadingcached_network_image
Error handlingtry/catch + SnackBar
Auth guardgo_router redirect based on token

📌 Study Checklist