10 — Flutter client and the first vertical slice
Goal
Connect Flutter to the Go service and fetch the demo task on a button tap.
Step 1: Add the generated client dependency
From field-notes:
cd flutter_app
flutter pub add grpc protobuf
cd ..
Generate the Dart files if you have not already:
protoc -I api --dart_out=grpc:flutter_app/lib/src/generated api/fieldnotes/v1/task.proto
Step 2: Create a gRPC repository
Create flutter_app/lib/task_repository.dart:
import 'package:grpc/grpc.dart';
import 'src/generated/fieldnotes/v1/task.pb.dart';
import 'src/generated/fieldnotes/v1/task.pbgrpc.dart';
class TaskRepository {
TaskRepository(String host)
: _channel = ClientChannel(
host,
port: 50051,
options: const ChannelOptions(
credentials: ChannelCredentials.insecure(),
),
) {
_client = TaskServiceClient(_channel);
}
final ClientChannel _channel;
late final TaskServiceClient _client;
Future<Task> getDemoTask() => _client.getTask(GetTaskRequest()..id = 'demo-task');
Future<void> close() => _channel.shutdown();
}
The insecure credential is only for local development. Use TLS for a deployed endpoint. The correct host depends on where the app runs:
- Android emulator:
10.0.2.2usually reaches the host computer. - iOS simulator: usually
localhost. - Physical phone: use the computer's LAN IP and allow the port through the local firewall.
The channel manages the long-lived connection and the generated stub exposes typed methods. Keeping both in a repository separates network code from widgets, and lets the UI be tested without embedding socket details in every screen. In production, share an appropriately scoped channel and close it when the owning app/service is disposed.
Step 3: Build a screen that calls the service
Replace flutter_app/lib/main.dart with:
import 'package:flutter/material.dart';
import 'task_repository.dart';
void main() => runApp(const FieldNotesApp());
class FieldNotesApp extends StatelessWidget {
const FieldNotesApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Field Notes',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const TaskPage(),
);
}
class TaskPage extends StatefulWidget {
const TaskPage({super.key});
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
late final TaskRepository _repository;
String _message = 'Tap Load to fetch the sample task.';
bool _loading = false;
@override
void initState() {
super.initState();
const host = String.fromEnvironment('GRPC_HOST', defaultValue: '10.0.2.2');
_repository = TaskRepository(host);
}
Future<void> _loadTask() async {
setState(() { _loading = true; _message = 'Loading…'; });
try {
final task = await _repository.getDemoTask();
if (!mounted) return;
setState(() => _message = '${task.title}\nStatus: ${task.status}');
} catch (error) {
if (!mounted) return;
setState(() => _message = 'Could not load task: $error');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
void dispose() {
_repository.close();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Field Notes')),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(_message, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: _loading ? null : _loadTask,
child: Text(_loading ? 'Loading…' : 'Load task'),
),
]),
),
);
}
StatefulWidget is used because this screen changes after an asynchronous response. _loading disables repeated taps, and _message holds the current display text. mounted prevents setState after the user has left the screen while the network call was waiting. Catching the error makes transport failure a visible UI state rather than an unhandled exception.
Step 4: Run both sides
Terminal A, from the repo root: go run ./server/cmd/api. Terminal B: cd flutter_app; flutter run. Tap Load task. The sample title and status should appear. Stop the Go process and tap again; the screen should show an error instead of crashing.
Checkpoint
Run against your device and choose the right GRPC_HOST:
flutter run --dart-define=GRPC_HOST=10.0.2.2
Explain why the repository owns the channel and why a widget's build method should not create a new channel on every rebuild.
Troubleshooting
SocketException/ connection refused: check the Go server is running and the host/port match.- Android cleartext transport issue: local insecure gRPC may require Android network security configuration; use the current Flutter/gRPC platform guidance for your target. Do not disable transport security in a release app.
- Generated import not found: regenerate from repo root and confirm the
lib/src/generated/fieldnotes/v1directory exists.