Files
zeekay aa258b0988 Add the iOS lane: hanzo-templates had zero of them
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.
2026-07-27 23:25:25 -07:00

166 lines
5.2 KiB
Dart

import 'package:flutter/material.dart';
import 'data.dart';
import 'detail.dart';
import 'theme.dart';
void main() => runApp(const HanzoApp());
class HanzoApp extends StatelessWidget {
const HanzoApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Hanzo Flutter',
debugShowCheckedModeBanner: false,
theme: hanzoTheme(),
home: const Shell(),
);
}
/// NavigationBar + IndexedStack: each tab keeps its own scroll position and
/// state, which is what users expect and what a naive `switch` throws away.
class Shell extends StatefulWidget {
const Shell({super.key});
@override
State<Shell> createState() => _ShellState();
}
class _ShellState extends State<Shell> {
int _tab = 0;
@override
Widget build(BuildContext context) => Scaffold(
body: IndexedStack(
index: _tab,
children: const [FeedTab(), SearchTab(), AboutTab()],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.grid_view_outlined), selectedIcon: Icon(Icons.grid_view), label: 'Feed'),
NavigationDestination(
icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'),
NavigationDestination(
icon: Icon(Icons.info_outline), selectedIcon: Icon(Icons.info), label: 'About'),
],
),
);
}
class FeedTab extends StatelessWidget {
const FeedTab({super.key});
@override
Widget build(BuildContext context) => CustomScrollView(
slivers: [
const SliverAppBar.large(title: Text('Hanzo Flutter')),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList.separated(
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => ItemCard(item: items[i]),
),
),
],
);
}
class ItemCard extends StatelessWidget {
const ItemCard({super.key, required this.item});
final Item item;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailPage(item: item)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.tag.toUpperCase(),
style: text.labelSmall?.copyWith(color: seed, letterSpacing: 1)),
const SizedBox(height: 6),
Text(item.title, style: text.titleMedium),
const SizedBox(height: 4),
Text(item.blurb, style: text.bodyMedium?.copyWith(color: Colors.white60)),
],
),
),
),
);
}
}
class SearchTab extends StatefulWidget {
const SearchTab({super.key});
@override
State<SearchTab> createState() => _SearchTabState();
}
class _SearchTabState extends State<SearchTab> {
String _q = '';
@override
Widget build(BuildContext context) {
final hits = items
.where((i) => '${i.title}${i.blurb}${i.tag}'.toLowerCase().contains(_q.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SearchBar(
hintText: 'Search',
leading: const Icon(Icons.search),
onChanged: (v) => setState(() => _q = v),
),
const SizedBox(height: 12),
Expanded(
child: hits.isEmpty
? const Center(child: Text('No matches.', style: TextStyle(color: Colors.white54)))
: ListView.builder(
itemCount: hits.length,
itemBuilder: (context, i) => ListTile(
title: Text(hits[i].title),
trailing: Text(hits[i].tag, style: const TextStyle(color: Colors.white54)),
onTap: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => DetailPage(item: hits[i]))),
),
),
),
],
),
),
);
}
}
class AboutTab extends StatelessWidget {
const AboutTab({super.key});
@override
Widget build(BuildContext context) => SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: const [
ListTile(title: Text('Framework'), trailing: Text('Flutter', style: TextStyle(color: Colors.white54))),
Divider(height: 1),
ListTile(title: Text('Targets'), trailing: Text('iOS · Android · web', style: TextStyle(color: Colors.white54))),
Divider(height: 1),
ListTile(title: Text('Design'), trailing: Text('Material 3', style: TextStyle(color: Colors.white54))),
],
),
);
}