mirror of
https://github.com/hanzo-templates/flutter-chat.git
synced 2026-10-30 20:52:58 +00:00
The catalogue shipped 71 web templates and one EMPTY `mobile` repo, so "build an app" had no starting point on the platform people actually ship to. This is the iOS lane — Expo/React Native and Flutter for cross-platform, SwiftUI for native. The constraint that shaped every file here: an iOS archive needs macOS + Xcode + a signing identity, and our arc fleet has none. hanzoai has 519 registered runners and every one reports `os: linux`; luxfi has 151, same story; zooai has none. The only macOS builds in the org today (hanzoai/world build-desktop.yml) run on GitHub-HOSTED macos-14, which is exactly what the house CI rule says not to do. So `hanzo.yml` declares only what a Linux arc runner can genuinely execute — typecheck + web bundle for Expo, analyze + test + web for Flutter, `swiftc -parse` for SwiftUI — and the macOS archive lives in a separate `workflow_dispatch`-only `ios.yml`. A job pinned to a label no runner advertises would sit queued forever if it fired on push; parking it is honest, a permanently-yellow check is not. The README of every template states the gap and the one command that closes it. Same reasoning on the demos. Expo and Flutter both have a real web target, so `<slug>.hanzo.app` serves the SAME source the simulator runs. SwiftUI has no web target, so those two templates ship with no demo link rather than a screenshot pretending to be one. Upstreams are permissive only and recorded per template: expo/expo templates (MIT), flutter/samples (BSD-3-Clause), XcodeGen (MIT). Second measured gap, found while wiring this up: `hanzo-templates` is not in arcd's `orgs:` list either (hanzoai, luxfi, zooai, parsdao, zenlm), so the Linux jobs would queue with no runner too. Both gaps are one installer re-run, no code change; both are spelled out in every README rather than left to be discovered on the first red build. Third gap, found by probing rather than assuming, and FIXED here: every run in this org died as `startup_failure` with no logs and no annotation. A bare `runs-on: [self-hosted, linux]` probe queued fine while the same job importing hanzoai/ci failed identically at @v1 and at a pinned SHA — so it was never the tag. The org defaulted GITHUB_TOKEN to `read`, and the reusable declares `permissions: packages: write`; a caller cannot exceed the org maximum, so GitHub kills the run before creating a job. hanzo-templates now defaults to `write` like hanzoai/luxfi/zooai, and runs reach `queued` — the honest state, waiting on gap 2.
178 lines
5.6 KiB
Dart
178 lines
5.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'hanzo.dart';
|
|
import 'settings.dart';
|
|
import 'theme.dart';
|
|
|
|
void main() => runApp(const ChatApp());
|
|
|
|
class ChatApp extends StatelessWidget {
|
|
const ChatApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => MaterialApp(
|
|
title: 'Hanzo Chat',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: hanzoTheme(),
|
|
home: const ChatPage(),
|
|
);
|
|
}
|
|
|
|
class ChatPage extends StatefulWidget {
|
|
const ChatPage({super.key});
|
|
@override
|
|
State<ChatPage> createState() => _ChatPageState();
|
|
}
|
|
|
|
class _ChatPageState extends State<ChatPage> {
|
|
static const _greeting =
|
|
Msg('assistant', 'Ask me anything. Set a Hanzo API key in Settings to talk to a real model.');
|
|
|
|
final _msgs = <Msg>[_greeting];
|
|
final _input = TextEditingController();
|
|
final _scroll = ScrollController();
|
|
bool _busy = false;
|
|
String? _err;
|
|
|
|
@override
|
|
void dispose() {
|
|
_input.dispose();
|
|
_scroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final text = _input.text.trim();
|
|
if (text.isEmpty || _busy) return;
|
|
|
|
// Push the user turn plus an empty assistant turn, then stream into it.
|
|
final history = [..._msgs.where((m) => m != _greeting), Msg('user', text)];
|
|
setState(() {
|
|
_input.clear();
|
|
_err = null;
|
|
_busy = true;
|
|
_msgs
|
|
..clear()
|
|
..addAll([...history, const Msg('assistant', '')]);
|
|
});
|
|
|
|
try {
|
|
await for (final chunk in Hanzo.instance.complete(history)) {
|
|
setState(() => _msgs[_msgs.length - 1] = Msg('assistant', _msgs.last.content + chunk));
|
|
_toBottom();
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_err = '$e';
|
|
_msgs
|
|
..clear()
|
|
..addAll(history);
|
|
});
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
void _toBottom() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent);
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Hanzo Chat'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.settings_outlined),
|
|
onPressed: () => Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (_) => const SettingsPage())),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView.separated(
|
|
controller: _scroll,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _msgs.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
|
itemBuilder: (_, i) => Bubble(msg: _msgs[i]),
|
|
),
|
|
),
|
|
if (_err != null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(_err!, style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13)),
|
|
),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _input,
|
|
minLines: 1,
|
|
maxLines: 5,
|
|
onSubmitted: (_) => _send(),
|
|
decoration: InputDecoration(
|
|
hintText: 'Message ${Hanzo.instance.model}',
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _send,
|
|
style: FilledButton.styleFrom(
|
|
shape: const CircleBorder(),
|
|
padding: const EdgeInsets.all(14),
|
|
),
|
|
child: _busy
|
|
? const SizedBox(
|
|
width: 18, height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
: const Icon(Icons.arrow_upward, size: 18),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
class Bubble extends StatelessWidget {
|
|
const Bubble({super.key, required this.msg});
|
|
final Msg msg;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final mine = msg.role == 'user';
|
|
return Align(
|
|
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.86),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: mine ? seed : const Color(0xFF141417),
|
|
border: mine ? null : Border.all(color: const Color(0xFF232329)),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(18),
|
|
topRight: const Radius.circular(18),
|
|
bottomLeft: Radius.circular(mine ? 18 : 4),
|
|
bottomRight: Radius.circular(mine ? 4 : 18),
|
|
),
|
|
),
|
|
child: Text(
|
|
msg.content.isEmpty ? '…' : msg.content,
|
|
style: TextStyle(color: mine ? Colors.white : Colors.white.withValues(alpha: 0.92), height: 1.4),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|