feat(block2): Ğ1 — publish a Ğ1 address + 'Pay in Ğ1' in chat (levels 1-2)

The optional Ğ1 (free currency) integration, per g1-integration.md.
- Profile: an optional 'Ğ1 address' field, persisted locally and published in
  your NIP-01 kind:0 metadata (separate from the Nostr key).
- commons_core: UserProfile/ProfileTransport carry a 'g1' field (round-trip
  tested).
- Chat: when the peer published a Ğ1 address, a 'Pay in Ğ1' app-bar action opens
  their wallet (url_launcher) so value moves in the wallet, not the app — copies
  the address as a fallback. No payment rails in-app (sharing-model §4.3).

NOTE (vjrj): the wallet deep-link uses the Cesium web wallet as a universal
fallback; swap the URI in chat_screen._payG1 for the Ğ1nkgo/Ğecko scheme you
prefer. Analyzer clean (both packages); profile transport test covers g1.
This commit is contained in:
vjrj 2026-07-10 13:06:38 +02:00
parent d0d2ecb132
commit d7136ec2c2
13 changed files with 138 additions and 14 deletions

View file

@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../i18n/strings.g.dart';
import '../services/message_store.dart';
@ -45,6 +47,7 @@ class _ChatScreenState extends State<ChatScreen> {
TrustCubit? _trust;
bool _loading = true;
String? _peerName;
String? _peerG1;
final _input = TextEditingController();
@override
@ -94,9 +97,15 @@ class _ChatScreenState extends State<ChatScreen> {
unawaited(() async {
try {
final profile = await live.profile.fetch(widget.peerPubkey);
if (profile != null && profile.name.isNotEmpty && mounted) {
await widget.profileCache!.setName(widget.peerPubkey, profile.name);
setState(() => _peerName = profile.name);
if (profile != null && mounted) {
if (profile.name.isNotEmpty) {
await widget.profileCache!
.setName(widget.peerPubkey, profile.name);
}
setState(() {
if (profile.name.isNotEmpty) _peerName = profile.name;
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
});
}
} catch (_) {
// best effort
@ -105,6 +114,30 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
/// Opens the peer's Ğ1 wallet so you can pay them (Ğ1 is separate from the
/// Nostr key this uses the address they published in their profile). Falls
/// back to copying the address if no wallet handles the link.
///
/// NOTE: confirm the deep-link scheme for the target wallet (Ğ1nkgo / Ğecko /
/// Cesium) this uses the Cesium web wallet as a universal fallback.
Future<void> _payG1() async {
final g1 = _peerG1;
if (g1 == null) return;
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
final uri = Uri.parse('https://demo.cesium.app/#/app/wallet/$g1/');
var launched = false;
try {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
launched = false;
}
if (!launched && mounted) {
await Clipboard.setData(ClipboardData(text: g1));
messenger.showSnackBar(SnackBar(content: Text(t.chat.g1Copied)));
}
}
Future<void> _send() async {
final cubit = _messages;
if (cubit == null || _input.text.trim().isEmpty) return;
@ -126,9 +159,19 @@ class _ChatScreenState extends State<ChatScreen> {
Widget build(BuildContext context) {
final messages = _messages;
final trust = _trust;
final t = context.t;
return Scaffold(
appBar: AppBar(
title: Text(_peerName ?? shortPubkey(widget.peerPubkey)),
actions: [
if (_peerG1 != null)
IconButton(
key: const Key('chat.payG1'),
icon: const Icon(Icons.payments_outlined),
tooltip: t.chat.payG1,
onPressed: _payG1,
),
],
),
body: _loading || messages == null || trust == null
? const Center(child: CircularProgressIndicator())

View file

@ -30,6 +30,7 @@ class ProfileScreen extends StatefulWidget {
class _ProfileScreenState extends State<ProfileScreen> {
final _name = TextEditingController();
final _about = TextEditingController();
final _g1 = TextEditingController();
bool _loading = true;
bool _saving = false;
@ -42,6 +43,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
Future<void> _load() async {
_name.text = await widget.profileStore.name();
_about.text = await widget.profileStore.about();
_g1.text = await widget.profileStore.g1();
if (mounted) setState(() => _loading = false);
}
@ -57,7 +59,8 @@ class _ProfileScreenState extends State<ProfileScreen> {
final messenger = ScaffoldMessenger.of(context);
final t = context.t;
setState(() => _saving = true);
await widget.profileStore.save(name: _name.text, about: _about.text);
await widget.profileStore
.save(name: _name.text, about: _about.text, g1: _g1.text);
// Best-effort publish to the network so peers see the name.
final relays = await widget.settings.relayUrls();
@ -67,6 +70,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
await session.profile.publish(
name: _name.text.trim(),
about: _about.text.trim(),
g1: _g1.text.trim(),
);
await session.close();
} catch (_) {
@ -82,6 +86,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
void dispose() {
_name.dispose();
_about.dispose();
_g1.dispose();
super.dispose();
}
@ -116,6 +121,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
helperText: t.profile.aboutHint,
),
),
const SizedBox(height: 16),
TextField(
key: const Key('profile.g1'),
controller: _g1,
decoration: InputDecoration(
labelText: t.profile.g1,
helperText: t.profile.g1Hint,
helperMaxLines: 2,
),
),
const SizedBox(height: 24),
FilledButton(
key: const Key('profile.save'),