🏠 Home / Hub

Flutter 07 — HTTP API & Local Storage

Fetch data from REST APIs, handle async UI with FutureBuilder, and persist data locally with shared_preferences, Hive, and SQLite.

1. HTTP Package Setup

The http package is the simplest way to make network requests in Flutter. Add it to pubspec.yaml.

# pubspec.yaml
dependencies:
  http: ^1.2.1
// Import in your Dart files
import 'dart:convert';
import 'package:http/http.dart' as http;

Android — allow internet access

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

iOS — allow arbitrary HTTP loads (development only)

<!-- ios/Runner/Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>
For production iOS apps, configure specific domains in NSExceptionDomains rather than allowing all arbitrary loads.

2. GET Request & JSON Parsing

// ---- Model class with fromJson factory ----
class Post {
  final int id;
  final String title;
  final String body;
  final int userId;

  const Post({
    required this.id,
    required this.title,
    required this.body,
    required this.userId,
  });

  // Parse from JSON map
  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id:     json['id']     as int,
      title:  json['title']  as String,
      body:   json['body']   as String,
      userId: json['userId'] as int,
    );
  }

  // Convert to JSON map
  Map<String, dynamic> toJson() => {
    'id':     id,
    'title':  title,
    'body':   body,
    'userId': userId,
  };
}

// ---- Fetch a single object ----
Future<Post> fetchPost(int id) async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/$id');
  final response = await http.get(url);

  if (response.statusCode == 200) {
    final Map<String, dynamic> json = jsonDecode(response.body);
    return Post.fromJson(json);
  } else {
    throw Exception('Failed to load post (${response.statusCode})');
  }
}

// ---- Fetch a list ----
Future<List<Post>> fetchPosts() async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final response = await http.get(url);

  if (response.statusCode == 200) {
    final List<dynamic> jsonList = jsonDecode(response.body);
    return jsonList.map((json) => Post.fromJson(json)).toList();
  } else {
    throw Exception('Failed to load posts');
  }
}

3. POST, PUT, DELETE Requests

// ---- POST — create a new resource ----
Future<Post> createPost(String title, String body) async {
  final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');

  final response = await http.post(
    url,
    headers: {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer YOUR_TOKEN_HERE',
    },
    body: jsonEncode({
      'title':  title,
      'body':   body,
      'userId': 1,
    }),
  );

  if (response.statusCode == 201) {
    return Post.fromJson(jsonDecode(response.body));
  } else {
    throw Exception('Failed to create post: ${response.statusCode}');
  }
}

// ---- PUT — full update ----
Future<Post> updatePost(int id, String title, String body) async {
  final response = await http.put(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'id': id, 'title': title, 'body': body, 'userId': 1}),
  );
  if (response.statusCode == 200) return Post.fromJson(jsonDecode(response.body));
  throw Exception('Update failed');
}

// ---- PATCH — partial update ----
Future<void> patchTitle(int id, String newTitle) async {
  await http.patch(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'title': newTitle}),
  );
}

// ---- DELETE ----
Future<void> deletePost(int id) async {
  final response = await http.delete(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
    headers: {'Authorization': 'Bearer YOUR_TOKEN'},
  );
  if (response.statusCode != 200) {
    throw Exception('Delete failed: ${response.statusCode}');
  }
}

// ---- GET with query parameters ----
Future<List<Post>> searchPosts(String query) async {
  final url = Uri.parse('https://api.example.com/posts').replace(
    queryParameters: {'q': query, 'limit': '10', 'page': '1'},
  );
  // URL becomes: https://api.example.com/posts?q=flutter&limit=10&page=1
  final response = await http.get(url);
  // ...
}

4. FutureBuilder

FutureBuilder integrates async operations directly into the widget tree. It handles loading, error, and data states automatically.

class PostListScreen extends StatelessWidget {
  const PostListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Posts')),
      body: FutureBuilder<List<Post>>(
        // The future to wait for
        future: fetchPosts(),

        // Builder is called whenever the future state changes
        builder: (context, snapshot) {

          // State 1: Still loading
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          // State 2: Error occurred
          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error_outline, size: 64, color: Colors.red),
                  const SizedBox(height: 16),
                  Text('Error: ${snapshot.error}'),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () {}, // trigger rebuild to retry
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }

          // State 3: Data arrived
          if (!snapshot.hasData || snapshot.data!.isEmpty) {
            return const Center(child: Text('No posts found.'));
          }

          final posts = snapshot.data!;
          return ListView.builder(
            itemCount: posts.length,
            itemBuilder: (context, index) {
              final post = posts[index];
              return ListTile(
                leading: CircleAvatar(child: Text('${post.id}')),
                title: Text(post.title, maxLines: 1, overflow: TextOverflow.ellipsis),
                subtitle: Text(post.body, maxLines: 2, overflow: TextOverflow.ellipsis),
              );
            },
          );
        },
      ),
    );
  }
}
FutureBuilder re-runs the future every time the widget rebuilds. To prevent this, store the Future in a variable in initState(), not directly in build().

5. Error Handling Pattern

// Custom exception class
class ApiException implements Exception {
  final String message;
  final int? statusCode;
  ApiException(this.message, {this.statusCode});

  @override
  String toString() => 'ApiException: $message (HTTP $statusCode)';
}

// Robust fetch with detailed error handling
Future<List<Post>> fetchPostsSafe() async {
  try {
    final response = await http.get(
      Uri.parse('https://api.example.com/posts'),
    ).timeout(const Duration(seconds: 10));  // timeout

    switch (response.statusCode) {
      case 200:
        final data = jsonDecode(response.body) as List;
        return data.map((j) => Post.fromJson(j)).toList();
      case 401:
        throw ApiException('Unauthorized — please log in again', statusCode: 401);
      case 403:
        throw ApiException('Forbidden', statusCode: 403);
      case 404:
        throw ApiException('Resource not found', statusCode: 404);
      case 500:
        throw ApiException('Server error — please try later', statusCode: 500);
      default:
        throw ApiException('Unexpected error', statusCode: response.statusCode);
    }
  } on TimeoutException {
    throw ApiException('Request timed out — check your connection');
  } on SocketException {
    throw ApiException('No internet connection');
  } on FormatException {
    throw ApiException('Invalid response format from server');
  }
}

// Show error in UI with SnackBar
void _handleError(BuildContext context, Object e) {
  final message = e is ApiException ? e.message : 'An error occurred';
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      backgroundColor: Colors.red.shade700,
      action: SnackBarAction(
        label: 'Dismiss',
        onPressed: () {},
        textColor: Colors.white,
      ),
    ),
  );
}

6. shared_preferences — Simple Key-Value Storage

Use shared_preferences for simple persistent key-value data: tokens, settings, flags, last-seen timestamps.

# pubspec.yaml
dependencies:
  shared_preferences: ^2.3.1
import 'package:shared_preferences/shared_preferences.dart';

class PrefsService {
  // Always use late and initialize in main() or on first access
  static late SharedPreferences _prefs;

  static Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  // ---- Write ----
  static Future<void> setString(String key, String value) =>
      _prefs.setString(key, value);

  static Future<void> setInt(String key, int value) =>
      _prefs.setInt(key, value);

  static Future<void> setBool(String key, bool value) =>
      _prefs.setBool(key, value);

  static Future<void> setStringList(String key, List<String> value) =>
      _prefs.setStringList(key, value);

  // ---- Read ----
  static String?       getString(String key) => _prefs.getString(key);
  static int?          getInt(String key)    => _prefs.getInt(key);
  static bool          getBool(String key)   => _prefs.getBool(key) ?? false;
  static List<String>? getStringList(String key) => _prefs.getStringList(key);

  // ---- Remove / Clear ----
  static Future<void> remove(String key) => _prefs.remove(key);
  static Future<void> clear()            => _prefs.clear();

  // ---- Typed helpers ----
  static Future<void> saveToken(String token) => setString('auth_token', token);
  static String?       getToken()              => getString('auth_token');
  static bool          isLoggedIn()            => getToken() != null;
  static Future<void> logout()                => remove('auth_token');
}

// Usage in main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await PrefsService.init();
  runApp(const MyApp());
}

// Usage anywhere
await PrefsService.saveToken('eyJhbGciOi...');
final token = PrefsService.getToken();
print(PrefsService.isLoggedIn());   // true

7. Hive — Local NoSQL Database

Hive is a fast, pure-Dart key-value database. It stores objects in typed boxes and is much faster than SQLite for simple object storage.

# pubspec.yaml
dependencies:
  hive_flutter: ^1.1.0

dev_dependencies:
  hive_generator: ^2.0.1
  build_runner: ^2.4.9
import 'package:hive_flutter/hive_flutter.dart';

// Initialize in main()
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();             // sets app documents dir automatically

  // Register adapters (for typed objects)
  Hive.registerAdapter(TodoAdapter());

  // Open boxes before using them
  await Hive.openBox<String>('settings');
  await Hive.openBox<Todo>('todos');

  runApp(const MyApp());
}

// ---- Using a simple string box ----
final settingsBox = Hive.box<String>('settings');
settingsBox.put('theme', 'dark');
final theme = settingsBox.get('theme', defaultValue: 'light');
settingsBox.delete('theme');

// ---- Typed objects with HiveType and HiveField annotations ----
// Run: flutter pub run build_runner build
@HiveType(typeId: 0)
class Todo extends HiveObject {
  @HiveField(0) late String id;
  @HiveField(1) late String title;
  @HiveField(2) late bool isDone;

  Todo({required this.id, required this.title, this.isDone = false});
}

// CRUD on a typed box
final box = Hive.box<Todo>('todos');

// Create
box.put('todo_1', Todo(id: 'todo_1', title: 'Buy milk'));

// Read all
final allTodos = box.values.toList();

// Update
final todo = box.get('todo_1')!;
todo.isDone = true;
todo.save();          // HiveObject.save() updates in-place

// Delete
box.delete('todo_1');

// Listen to changes with ValueListenableBuilder
ValueListenableBuilder(
  valueListenable: box.listenable(),
  builder: (context, Box<Todo> box, child) {
    return ListView.builder(
      itemCount: box.length,
      itemBuilder: (context, i) => ListTile(title: Text(box.getAt(i)!.title)),
    );
  },
)

8. SQLite with sqflite

Use sqflite for relational data with multiple tables, complex queries, or when migrating from a web backend that uses SQL.

# pubspec.yaml
dependencies:
  sqflite: ^2.3.3+1
  path: ^1.9.0
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static Database? _database;

  static Future<Database> get database async {
    _database ??= await _initDatabase();
    return _database!;
  }

  static Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'myapp.db');

    return openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE todos (
            id      INTEGER PRIMARY KEY AUTOINCREMENT,
            title   TEXT NOT NULL,
            body    TEXT,
            isDone  INTEGER NOT NULL DEFAULT 0,
            created TEXT NOT NULL
          )
        ''');
      },
      onUpgrade: (db, oldVersion, newVersion) async {
        // Handle schema migrations here
        if (oldVersion < 2) {
          await db.execute('ALTER TABLE todos ADD COLUMN priority INTEGER DEFAULT 0');
        }
      },
    );
  }

  // INSERT
  static Future<int> insertTodo(Map<String, dynamic> todo) async {
    final db = await database;
    return db.insert('todos', todo, conflictAlgorithm: ConflictAlgorithm.replace);
  }

  // SELECT ALL
  static Future<List<Map<String, dynamic>>> getTodos() async {
    final db = await database;
    return db.query('todos', orderBy: 'created DESC');
  }

  // SELECT WHERE
  static Future<List<Map<String, dynamic>>> getPending() async {
    final db = await database;
    return db.query('todos', where: 'isDone = ?', whereArgs: [0]);
  }

  // UPDATE
  static Future<int> updateTodo(int id, Map<String, dynamic> updates) async {
    final db = await database;
    return db.update('todos', updates, where: 'id = ?', whereArgs: [id]);
  }

  // DELETE
  static Future<int> deleteTodo(int id) async {
    final db = await database;
    return db.delete('todos', where: 'id = ?', whereArgs: [id]);
  }

  // RAW QUERY
  static Future<List<Map<String, dynamic>>> search(String q) async {
    final db = await database;
    return db.rawQuery('SELECT * FROM todos WHERE title LIKE ?', ['%$q%']);
  }
}

// Usage
final id = await DatabaseHelper.insertTodo({
  'title': 'Buy groceries',
  'isDone': 0,
  'created': DateTime.now().toIso8601String(),
});
final todos = await DatabaseHelper.getTodos();

9. API Service Class Pattern

Keep HTTP logic separate from UI in a dedicated service class. Widgets only call service methods and react to results.

// lib/services/api_service.dart

class ApiService {
  static const _baseUrl = 'https://jsonplaceholder.typicode.com';

  // Reusable headers
  static Map<String, String> get _headers => {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ${PrefsService.getToken() ?? ''}',
  };

  // Generic GET
  static Future<T> get<T>(String path, T Function(dynamic json) fromJson) async {
    final response = await http.get(
      Uri.parse('$_baseUrl$path'),
      headers: _headers,
    ).timeout(const Duration(seconds: 15));

    _checkStatus(response);
    return fromJson(jsonDecode(response.body));
  }

  // Generic POST
  static Future<T> post<T>(
    String path,
    Map<String, dynamic> body,
    T Function(dynamic json) fromJson,
  ) async {
    final response = await http.post(
      Uri.parse('$_baseUrl$path'),
      headers: _headers,
      body: jsonEncode(body),
    ).timeout(const Duration(seconds: 15));

    _checkStatus(response);
    return fromJson(jsonDecode(response.body));
  }

  static void _checkStatus(http.Response response) {
    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw ApiException('HTTP ${response.statusCode}', statusCode: response.statusCode);
    }
  }

  // ---- Specific API methods ----
  static Future<List<Post>> fetchPosts() =>
    get('/posts', (json) => (json as List).map((j) => Post.fromJson(j)).toList());

  static Future<Post> fetchPost(int id) =>
    get('/posts/$id', (json) => Post.fromJson(json));

  static Future<Post> createPost(String title, String body) =>
    post('/posts', {'title': title, 'body': body, 'userId': 1}, (json) => Post.fromJson(json));
}

// ---- Widget uses the service ----
class PostsScreen extends StatelessWidget {
  const PostsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Post>>(
      future: ApiService.fetchPosts(),   // clean call, no HTTP code here
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) return Text('Error: ${snapshot.error}');
        return ListView.builder(
          itemCount: snapshot.data!.length,
          itemBuilder: (ctx, i) => ListTile(title: Text(snapshot.data![i].title)),
        );
      },
    );
  }
}

📌 Study Checklist