🏠 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
| Feature | Flutter Approach |
| HTTP calls | http package + custom ApiClient wrapper |
| Token storage | flutter_secure_storage (encrypted) |
| State management | Provider + ChangeNotifier |
| Navigation | go_router (named routes, guards) |
| Loading state | CircularProgressIndicator + Consumer |
| Pull to refresh | RefreshIndicator widget |
| Image loading | cached_network_image |
| Error handling | try/catch + SnackBar |
| Auth guard | go_router redirect based on token |
📌 Study Checklist
- အပေါ်က concept ကို တစ်ကြောင်းချင်းဖတ်ပြီး example ကို ကိုယ်တိုင်ပြန်ရေးကြည့်ပါ။
- Code/command ပါတဲ့ lesson ဆိုရင် value/name/path တစ်ခုခု ပြောင်းပြီး result ဘာကွာလဲ စမ်းပါ။
- မမှတ်မိသေးတဲ့ keyword 3 ခုကို notebook ထဲရေးပြီး နောက် lesson မသွားခင် ပြန်ရှင်းကြည့်ပါ။
- ပြီးရင် Home / Hub ကိုပြန်သွားပြီး next lesson ဆက်သင်ပါ။