DEV Community

Cover image for Build a 4K Wallpaper App in Flutter with a Free Wallpaper API (Step by Step)
Kodnex Technologies
Kodnex Technologies

Posted on Originally published at nexwall.kodnextech.com

Build a 4K Wallpaper App in Flutter with a Free Wallpaper API (Step by Step)

Disclosure: I build NexWall, the wallpaper API used in this tutorial. Most of the Flutter code here (pagination, image caching, MethodChannels) works the same with any JSON image API, so you can swap the data source if you prefer something else.

Full source code: kodnextechnologies/nexwall-flutter-wallpaper-app (MIT licensed)

Wallpaper apps are a common first "real" Flutter project. You get networking, grids, caching, pagination and a bit of native platform code. The awkward part is usually the content: where do you get a legal, consistent supply of high-resolution portrait images without scraping anything?

In this tutorial we'll build a small but complete wallpaper app with:

  • A horizontal row of category chips
  • An infinite-scroll grid of thumbnails
  • A full-screen preview
  • A "Set as wallpaper" button on Android, using a tiny MethodChannel and Android's WallpaperManager

For content we'll use the NexWall wallpaper REST API. It's a free wallpaper API tier with 100 requests/day, 60 requests/minute, and no credit card. It serves vertical 9:16 wallpapers up to 2160x3840 across 50+ categories (the free tier covers the non-premium categories).

1. Get an API key

Register at https://nexwall.kodnextech.com/developers/register, verify your email, and copy the key. Every request sends it as a Bearer token:

Authorization: Bearer YOUR_API_KEY
Accept: application/json
Enter fullscreen mode Exit fullscreen mode

Quick sanity check from a terminal:

curl "https://nexwall.kodnextech.com/api/developer/v1/categories" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json"
Enter fullscreen mode Exit fullscreen mode

You should get back something like this:

{
  "data": [
    {
      "id": 1,
      "name": "AMOLED & Pure Black",
      "slug": "dark-moody",
      "cover_image_url": "https://nexwall.kodnextech.com/storage/categories/amoled.webp",
      "wallpaper_count": 248,
      "is_premium": false
    }
  ],
  "plan": "free",
  "remaining_requests_today": 99
}
Enter fullscreen mode Exit fullscreen mode

Note remaining_requests_today. With 100 requests per day on the free tier, it's worth caching during development. More on that at the end.

2. Project setup

flutter create nexwall_demo
cd nexwall_demo
flutter pub add http cached_network_image
Enter fullscreen mode Exit fullscreen mode

We won't hard-code the key. Instead we'll pass it at build time:

flutter run --dart-define=NEXWALL_API_KEY=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

Important for production: a --dart-define value still ends up inside your APK/IPA, and anyone can extract it. That's fine for learning and prototypes. For a shipped app, put a small backend proxy in front of the API so the key stays on your server, and point baseUrl at your proxy instead. The NexWall docs recommend the same thing.

3. Models

The wallpaper feed (GET /wallpapers) returns a Laravel-style paginated payload: data, current_page, last_page, per_page, total, plus plan and remaining_requests_today. We only map the fields we use.

lib/models.dart:

class Category {
  final int id;
  final String name;
  final String? coverImageUrl;
  final int wallpaperCount;

  Category({
    required this.id,
    required this.name,
    this.coverImageUrl,
    required this.wallpaperCount,
  });

  factory Category.fromJson(Map<String, dynamic> json) => Category(
        id: json['id'] as int,
        name: json['name'] as String,
        coverImageUrl: json['cover_image_url'] as String?,
        wallpaperCount: (json['wallpaper_count'] ?? 0) as int,
      );
}

class Wallpaper {
  final int id;
  final String imageUrl;
  final String thumbnailUrl;
  final String? resolution;
  final String? tags;

  Wallpaper({
    required this.id,
    required this.imageUrl,
    required this.thumbnailUrl,
    this.resolution,
    this.tags,
  });

  factory Wallpaper.fromJson(Map<String, dynamic> json) => Wallpaper(
        id: json['id'] as int,
        imageUrl: json['image_url'] as String,
        // Fall back to the full image if a thumbnail is ever missing.
        thumbnailUrl: (json['thumbnail_url'] ?? json['image_url']) as String,
        resolution: json['resolution'] as String?,
        tags: json['tags'] as String?,
      );
}

class WallpaperPage {
  final List<Wallpaper> items;
  final int currentPage;
  final int lastPage;

  WallpaperPage({
    required this.items,
    required this.currentPage,
    required this.lastPage,
  });

  bool get hasMore => currentPage < lastPage;
}
Enter fullscreen mode Exit fullscreen mode

4. A small API client

The API has four GET endpoints. We'll use two of them: /categories and /wallpapers (with optional category_id, page, per_page, and sort). We also handle the status codes the API documents: 401 for a bad key, 429 for rate limits (with a Retry-After header), and 422 for invalid query parameters.

lib/nexwall_api.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'models.dart';

class NexWallException implements Exception {
  final String message;
  NexWallException(this.message);
  @override
  String toString() => message;
}

class NexWallApi {
  NexWallApi({
    required this.apiKey,
    this.baseUrl = 'https://nexwall.kodnextech.com/api/developer/v1',
    http.Client? client,
  }) : _client = client ?? http.Client();

  final String apiKey;
  final String baseUrl;
  final http.Client _client;

  Future<Map<String, dynamic>> _get(String path,
      [Map<String, String>? query]) async {
    final uri = Uri.parse('$baseUrl$path')
        .replace(queryParameters: (query == null || query.isEmpty) ? null : query);

    final res = await _client.get(uri, headers: {
      'Authorization': 'Bearer $apiKey',
      'Accept': 'application/json',
    });

    switch (res.statusCode) {
      case 200:
        return jsonDecode(res.body) as Map<String, dynamic>;
      case 401:
        throw NexWallException('Missing or invalid API key.');
      case 422:
        throw NexWallException('Invalid query parameters: ${res.body}');
      case 429:
        final retry = res.headers['retry-after'] ?? '?';
        throw NexWallException('Rate limit reached. Retry after $retry s.');
      default:
        throw NexWallException('Request failed (${res.statusCode}).');
    }
  }

  Future<List<Category>> categories() async {
    final json = await _get('/categories');
    return (json['data'] as List)
        .map((e) => Category.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<WallpaperPage> wallpapers({
    int page = 1,
    int perPage = 30,
    int? categoryId,
    String sort = 'newest', // newest | oldest | popular | random
  }) async {
    final json = await _get('/wallpapers', {
      'page': '$page',
      'per_page': '$perPage',
      'sort': sort,
      'type': 'image',
      if (categoryId != null) 'category_id': '$categoryId',
    });

    return WallpaperPage(
      items: (json['data'] as List)
          .map((e) => Wallpaper.fromJson(e as Map<String, dynamic>))
          .toList(),
      currentPage: json['current_page'] as int,
      lastPage: json['last_page'] as int,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

per_page accepts 1 to 100. I use 30 so one request fills a couple of screens without using a large share of the daily quota on scrolling.

5. The home screen: chips + infinite grid

lib/main.dart:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'models.dart';
import 'nexwall_api.dart';
import 'preview_screen.dart';

const apiKey = String.fromEnvironment('NEXWALL_API_KEY');

void main() {
  assert(apiKey.isNotEmpty, 'Pass --dart-define=NEXWALL_API_KEY=...');
  runApp(const WallpaperApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'NexWall Demo',
      theme: ThemeData.dark(useMaterial3: true),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final api = NexWallApi(apiKey: apiKey);
  final scroll = ScrollController();

  List<Category> categories = [];
  int? selectedCategoryId;

  final List<Wallpaper> wallpapers = [];
  int page = 1;
  bool hasMore = true;
  bool loading = false;
  String? error;

  @override
  void initState() {
    super.initState();
    _loadCategories();
    _loadMore();
    scroll.addListener(() {
      if (scroll.position.pixels > scroll.position.maxScrollExtent - 600) {
        _loadMore();
      }
    });
  }

  Future<void> _loadCategories() async {
    try {
      final result = await api.categories();
      setState(() => categories = result);
    } catch (e) {
      setState(() => error = e.toString());
    }
  }

  Future<void> _loadMore() async {
    if (loading || !hasMore) return;
    setState(() => loading = true);
    try {
      final result = await api.wallpapers(
        page: page,
        categoryId: selectedCategoryId,
      );
      setState(() {
        wallpapers.addAll(result.items);
        hasMore = result.hasMore;
        page = result.currentPage + 1;
        error = null;
      });
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => loading = false);
    }
  }

  void _selectCategory(int? id) {
    setState(() {
      selectedCategoryId = id;
      wallpapers.clear();
      page = 1;
      hasMore = true;
    });
    _loadMore();
  }

  @override
  void dispose() {
    scroll.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Wallpapers')),
      body: Column(
        children: [
          SizedBox(
            height: 56,
            child: ListView(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              children: [
                _chip('All', null),
                for (final c in categories) _chip(c.name, c.id),
              ],
            ),
          ),
          if (error != null)
            Padding(
              padding: const EdgeInsets.all(8),
              child: Text(error!, style: const TextStyle(color: Colors.redAccent)),
            ),
          Expanded(
            child: GridView.builder(
              controller: scroll,
              padding: const EdgeInsets.all(8),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 3,
                mainAxisSpacing: 8,
                crossAxisSpacing: 8,
                childAspectRatio: 9 / 16, // portrait wallpapers
              ),
              itemCount: wallpapers.length + (loading ? 1 : 0),
              itemBuilder: (context, i) {
                if (i >= wallpapers.length) {
                  return const Center(child: CircularProgressIndicator());
                }
                final w = wallpapers[i];
                return GestureDetector(
                  onTap: () => Navigator.push(
                    context,
                    MaterialPageRoute(builder: (_) => PreviewScreen(wallpaper: w)),
                  ),
                  child: Hero(
                    tag: 'wp-${w.id}',
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(12),
                      child: CachedNetworkImage(
                        imageUrl: w.thumbnailUrl,
                        fit: BoxFit.cover,
                        placeholder: (_, __) => Container(color: Colors.white10),
                        errorWidget: (_, __, ___) => const Icon(Icons.broken_image),
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget _chip(String label, int? id) => Padding(
        padding: const EdgeInsets.only(right: 8),
        child: ChoiceChip(
          label: Text(label),
          selected: selectedCategoryId == id,
          onSelected: (_) => _selectCategory(id),
        ),
      );
}
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • The grid uses thumbnail_url, not image_url. The full images go up to 2160x3840 and are far too heavy for a grid of 30 tiles.
  • childAspectRatio: 9 / 16 matches the portrait format, so nothing gets awkwardly cropped.
  • Images are served directly by URL. Loading an image file doesn't count as an API request; only the JSON endpoints do.

6. Full-screen preview

lib/preview_screen.dart:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'models.dart';

class PreviewScreen extends StatefulWidget {
  const PreviewScreen({super.key, required this.wallpaper});
  final Wallpaper wallpaper;

  @override
  State<PreviewScreen> createState() => _PreviewScreenState();
}

class _PreviewScreenState extends State<PreviewScreen> {
  static const channel = MethodChannel('nexwall/wallpaper');
  bool busy = false;

  // 1 = home screen, 2 = lock screen, 3 = both
  Future<void> _setWallpaper(int target) async {
    setState(() => busy = true);
    try {
      final res = await http.get(Uri.parse(widget.wallpaper.imageUrl));
      if (res.statusCode != 200) {
        throw Exception('Download failed (${res.statusCode})');
      }
      await channel.invokeMethod('setWallpaper', {
        'bytes': res.bodyBytes,
        'target': target,
      });
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(const SnackBar(content: Text('Wallpaper set')));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(SnackBar(content: Text('Failed: $e')));
      }
    } finally {
      if (mounted) setState(() => busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final w = widget.wallpaper;
    final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android;

    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        title: Text(w.resolution ?? ''),
      ),
      extendBodyBehindAppBar: true,
      body: Hero(
        tag: 'wp-${w.id}',
        child: CachedNetworkImage(
          imageUrl: w.imageUrl,
          fit: BoxFit.cover,
          width: double.infinity,
          height: double.infinity,
          placeholder: (_, __) => CachedNetworkImage(
            imageUrl: w.thumbnailUrl,
            fit: BoxFit.cover,
          ),
        ),
      ),
      floatingActionButton: isAndroid
          ? FloatingActionButton.extended(
              onPressed: busy ? null : () => _showTargets(context),
              icon: busy
                  ? const SizedBox(
                      width: 18, height: 18,
                      child: CircularProgressIndicator(strokeWidth: 2))
                  : const Icon(Icons.wallpaper),
              label: const Text('Set as wallpaper'),
            )
          : null,
    );
  }

  void _showTargets(BuildContext context) {
    showModalBottomSheet(
      context: context,
      builder: (_) => SafeArea(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          ListTile(title: const Text('Home screen'),
              onTap: () { Navigator.pop(context); _setWallpaper(1); }),
          ListTile(title: const Text('Lock screen'),
              onTap: () { Navigator.pop(context); _setWallpaper(2); }),
          ListTile(title: const Text('Both'),
              onTap: () { Navigator.pop(context); _setWallpaper(3); }),
        ]),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the thumbnail as the placeholder for the full image gives a nice "blur-up" effect for free, since the thumbnail is usually already in cache from the grid.

7. The Android side: a 30-line MethodChannel

There are pub.dev plugins for setting wallpapers, but the native code is small enough that I prefer owning it. Flutter's Uint8List arrives on the Kotlin side as a ByteArray.

android/app/src/main/kotlin/<your/package>/MainActivity.kt:

package com.example.nexwall_demo

import android.app.WallpaperManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayInputStream

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "nexwall/wallpaper")
            .setMethodCallHandler { call, result ->
                if (call.method != "setWallpaper") {
                    result.notImplemented()
                    return@setMethodCallHandler
                }
                val bytes = call.argument<ByteArray>("bytes")
                val target = call.argument<Int>("target") ?: 1
                if (bytes == null) {
                    result.error("NO_BYTES", "No image data received", null)
                    return@setMethodCallHandler
                }

                // WallpaperManager does disk + decode work: keep it off the UI thread.
                Thread {
                    try {
                        val wm = WallpaperManager.getInstance(applicationContext)
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                            val flags = when (target) {
                                1 -> WallpaperManager.FLAG_SYSTEM
                                2 -> WallpaperManager.FLAG_LOCK
                                else -> WallpaperManager.FLAG_SYSTEM or WallpaperManager.FLAG_LOCK
                            }
                            wm.setStream(ByteArrayInputStream(bytes), null, true, flags)
                        } else {
                            wm.setStream(ByteArrayInputStream(bytes))
                        }
                        runOnUiThread { result.success(true) }
                    } catch (e: Exception) {
                        runOnUiThread { result.error("SET_FAILED", e.message, null) }
                    }
                }.start()
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the permission to android/app/src/main/AndroidManifest.xml (it's a normal permission, so no runtime prompt). Also make sure INTERNET is in the main manifest. Flutter only adds it to the debug/profile manifests by default, which is a classic "works in debug, blank in release" bug:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SET_WALLPAPER" />
Enter fullscreen mode Exit fullscreen mode

Why setStream instead of decoding a Bitmap first? A 2160x3840 image is about 33 MB as an ARGB bitmap. Passing the stream lets the system handle decoding and avoids an easy out-of-memory crash on low-end devices.

iOS note: iOS has no public API for setting the wallpaper programmatically. The usual pattern is "Save to Photos", after which the user sets it from the Photos app. That's why the button above is Android-only.

8. Being nice to your quota

The free tier gives you 100 requests/day and 60/minute, which is plenty for building and testing if you avoid waste:

  • Cache categories. They rarely change. Fetch once per app launch, or store them for a day.
  • Use a sensible per_page. One request for 30 to 50 items is much better than many small ones.
  • Read the headers. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix timestamp; quotas reset at 00:00 UTC). On a 429, respect Retry-After.
  • Proxy + cache in production. A backend proxy keeps your key secret and lets many users share one cached response.

When you outgrow the free tier, Pro ($4.99/mo or ₹399/mo) raises the limit to 10,000 requests/day and unlocks all categories, including premium ones and commercial monetization rights. Ultra ($10.99/mo or ₹899/mo) adds looping MP4 live wallpapers via type=live. Read the Developer API License before shipping a monetized app.

Where to go next

  • Search: /wallpapers?search=neon matches tags (2 to 100 characters).
  • Discovery feed: sort=popular or sort=random makes a good "Explore" tab.
  • Auto-changer: schedule a daily sort=random&per_page=1 call with WorkManager and reuse the MethodChannel above.

Useful links:

Once more for transparency: I work on NexWall. If something in the API is confusing or missing for your use case, tell me in the comments. That kind of feedback directly shapes what we build next.

Top comments (0)