Rebrand CommGate to gsmnode and adopt new design system
Rename the whole project from CommGate to gsmnode and re-skin every surface onto the new signal-green + ink design system (Space Grotesk / IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark, lowercase gsm+node wordmark). - Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme, new logo/favicon assets; both builds verified. - Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive launcher icons; rename Android applicationId/package to app.gsmnode.phone; bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified. - API Server (Go): panel routes, User-Agent, health service id, branding. - Home Assistant Plugin: rename integration domain sms_gateway to gsmnode (folder, DOMAIN, services, entity ids, classes); py_compile verified. - Update all READMEs; add .gitignore (excludes Design/, build artifacts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/// Compile-time defaults. The API base URL is also user-editable on the login
|
||||
/// screen and persisted via [Storage].
|
||||
class AppConfig {
|
||||
/// Default API Server base URL. Use the LAN IP of the machine running the
|
||||
/// API Server (not localhost — that points at the phone itself).
|
||||
///
|
||||
/// 10.0.2.2 is the Android emulator's alias for the host machine.
|
||||
static const String defaultApiBase = 'http://10.0.2.2:8080';
|
||||
|
||||
/// How often the gateway polls the API Server for pending messages.
|
||||
static const Duration pollInterval = Duration(seconds: 10);
|
||||
|
||||
/// How often the device sends a heartbeat ping.
|
||||
static const Duration pingInterval = Duration(minutes: 1);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'config.dart';
|
||||
import 'services/api_client.dart';
|
||||
import 'services/gateway_service.dart';
|
||||
import 'services/sms_service.dart';
|
||||
import 'services/storage.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
late Storage storage;
|
||||
late ApiClient apiClient;
|
||||
late GatewayService gateway;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
storage = await Storage.create();
|
||||
storage.apiBase ??= AppConfig.defaultApiBase;
|
||||
|
||||
apiClient = ApiClient(storage);
|
||||
gateway = GatewayService(apiClient, SmsService());
|
||||
|
||||
runApp(const GsmNodeApp());
|
||||
}
|
||||
|
||||
class GsmNodeApp extends StatelessWidget {
|
||||
const GsmNodeApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'gsmnode',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: gsmnodeLightTheme(),
|
||||
darkTheme: gsmnodeDarkTheme(),
|
||||
themeMode: ThemeMode.system,
|
||||
home: storage.isRegistered ? const HomeScreen() : const LoginScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// An outbound message handed to the device by the API Server.
|
||||
class GatewayMessage {
|
||||
final String id;
|
||||
final String type; // 'sms' or 'call'
|
||||
final List<String> phoneNumbers;
|
||||
final String textMessage;
|
||||
final int? simNumber;
|
||||
final String status;
|
||||
|
||||
GatewayMessage({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.phoneNumbers,
|
||||
required this.textMessage,
|
||||
this.simNumber,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
bool get isCall => type == 'call';
|
||||
|
||||
factory GatewayMessage.fromJson(Map<String, dynamic> json) {
|
||||
return GatewayMessage(
|
||||
id: json['id'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'sms',
|
||||
phoneNumbers: (json['phone_numbers'] as List<dynamic>? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
textMessage: json['text_message'] as String? ?? '',
|
||||
simNumber: json['sim_number'] as int?,
|
||||
status: json['status'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/gsmnode_mark.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _permsGranted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
gateway.addListener(_onChange);
|
||||
_checkPermissions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
gateway.removeListener(_onChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChange() => setState(() {});
|
||||
|
||||
Future<void> _checkPermissions() async {
|
||||
final sms = await Permission.sms.status;
|
||||
final phone = await Permission.phone.status;
|
||||
setState(() => _permsGranted = sms.isGranted && phone.isGranted);
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
// SMS + phone gate the gateway; notification lets the foreground service
|
||||
// post its ongoing notification on Android 13+.
|
||||
await [Permission.sms, Permission.phone, Permission.notification].request();
|
||||
await _checkPermissions();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
if (gateway.running) {
|
||||
gateway.stop();
|
||||
} else {
|
||||
gateway.start();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
gateway.stop();
|
||||
await storage.clearSession();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final running = gateway.running;
|
||||
final cg = context.cg;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GsmNodeMark(size: 22),
|
||||
SizedBox(width: 8),
|
||||
GsmNodeWordmark(size: 17),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Sign out',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_heroCard(running),
|
||||
const SizedBox(height: 12),
|
||||
if (!_permsGranted) ...[
|
||||
_permissionCard(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_infoCard(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'ACTIVITY',
|
||||
style: gsmMono(size: 10, color: cg.textMuted, letterSpacing: 1.4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _activityLog()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hero per the mobile UI kit: brand ring + mark when routing, ink when
|
||||
/// paused. The primary action carries the brand glow.
|
||||
Widget _heroCard(bool running) {
|
||||
final cg = context.cg;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: running ? cg.brandTint : cg.sunkenBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: running
|
||||
? GsmColors.green500.withValues(alpha: 0.22)
|
||||
: cg.borderSubtle,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: running ? GsmColors.green500 : cg.textMuted,
|
||||
boxShadow: running
|
||||
? [
|
||||
BoxShadow(
|
||||
color: GsmColors.green500.withValues(alpha: 0.28),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: const Center(
|
||||
child: GsmNodeMark(size: 44, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
running ? 'Gateway active' : 'Gateway paused',
|
||||
style: gsmDisplay(size: 20, color: cg.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
running
|
||||
? 'routing sms · mms · calls'
|
||||
: 'not routing — messages will queue',
|
||||
style: gsmMono(
|
||||
size: 12,
|
||||
color: running ? GsmColors.green500 : cg.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton.icon(
|
||||
onPressed: _permsGranted ? _toggle : null,
|
||||
icon: Icon(running ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(running ? 'Stop gateway' : 'Start gateway'),
|
||||
style: running
|
||||
? FilledButton.styleFrom(
|
||||
backgroundColor: cg.danger,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _permissionCard() {
|
||||
final cg = context.cg;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: cg.warningTint,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: cg.warning),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text('SMS & phone permissions are required to send and '
|
||||
'receive messages.'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _requestPermissions,
|
||||
child: const Text('Grant'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard() {
|
||||
final cg = context.cg;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
_infoRow('DEVICE', storage.deviceName ?? '—'),
|
||||
Divider(height: 16, color: cg.borderSubtle),
|
||||
_infoRow('ACCOUNT', storage.userEmail ?? '—'),
|
||||
Divider(height: 16, color: cg.borderSubtle),
|
||||
_infoRow('SERVER', storage.apiBase ?? '—'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(String label, String value) {
|
||||
final cg = context.cg;
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 76,
|
||||
child: Text(
|
||||
label,
|
||||
style: gsmMono(size: 9, color: cg.textMuted, letterSpacing: 1.3),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: gsmMono(size: 12, color: cg.textPrimary),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _activityLog() {
|
||||
final cg = context.cg;
|
||||
final entries = gateway.log;
|
||||
if (entries.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No activity yet.',
|
||||
style: TextStyle(color: cg.textMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 6),
|
||||
itemBuilder: (_, i) {
|
||||
final e = entries[i];
|
||||
final t = e.time;
|
||||
final hh = t.hour.toString().padLeft(2, '0');
|
||||
final mm = t.minute.toString().padLeft(2, '0');
|
||||
final ss = t.second.toString().padLeft(2, '0');
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: cg.card,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cg.borderSubtle),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: e.error ? cg.danger : cg.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(e.text, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'$hh:$mm:$ss',
|
||||
style: gsmMono(size: 10, color: cg.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/gsmnode_mark.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
/// Login + device registration. The user authenticates against the API Server,
|
||||
/// then this phone is registered as a gateway device.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _apiBase = TextEditingController(text: storage.apiBase ?? '');
|
||||
final _email = TextEditingController(text: storage.userEmail ?? '');
|
||||
final _password = TextEditingController();
|
||||
final _deviceName = TextEditingController(text: storage.deviceName ?? 'My Phone');
|
||||
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_apiBase.dispose();
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
_deviceName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
storage.apiBase = _apiBase.text.trim();
|
||||
await apiClient.login(_email.text.trim(), _password.text);
|
||||
|
||||
final deviceId = storage.deviceId ??
|
||||
'android-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await apiClient.registerDevice(
|
||||
deviceId: deviceId,
|
||||
name: _deviceName.text.trim().isEmpty ? 'My Phone' : _deviceName.text.trim(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error =
|
||||
e.status == 401 ? 'Invalid email or password.' : e.message);
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Could not connect: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cg = context.cg;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Center(child: GsmNodeMark(size: 56)),
|
||||
const SizedBox(height: 14),
|
||||
const Center(child: GsmNodeWordmark(size: 26)),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
'CONNECT THIS PHONE TO YOUR GATEWAY',
|
||||
textAlign: TextAlign.center,
|
||||
style: gsmMono(
|
||||
size: 10,
|
||||
color: cg.textMuted,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
TextField(
|
||||
controller: _apiBase,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Server URL',
|
||||
hintText: 'http://10.0.2.2:8080',
|
||||
),
|
||||
style: gsmMono(size: 14, color: cg.textPrimary),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _email,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _password,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _deviceName,
|
||||
decoration: const InputDecoration(labelText: 'Device name'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cg.dangerTint,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(_error!, style: TextStyle(color: cg.danger)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Sign in & register device'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/message.dart';
|
||||
import 'storage.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
final int status;
|
||||
final String message;
|
||||
ApiException(this.status, this.message);
|
||||
@override
|
||||
String toString() => 'ApiException($status): $message';
|
||||
}
|
||||
|
||||
/// Client for the API Server. Uses the user JWT for login/registration and the
|
||||
/// opaque device token for the mobile gateway endpoints.
|
||||
class ApiClient {
|
||||
final Storage storage;
|
||||
final http.Client _http;
|
||||
|
||||
ApiClient(this.storage, {http.Client? client})
|
||||
: _http = client ?? http.Client();
|
||||
|
||||
String get _base => (storage.apiBase ?? '').replaceAll(RegExp(r'/$'), '');
|
||||
|
||||
Uri _uri(String path) => Uri.parse('$_base$path');
|
||||
|
||||
Map<String, String> _headers(String? token) => {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
|
||||
dynamic _decode(http.Response res) {
|
||||
final body = res.body.isNotEmpty ? jsonDecode(res.body) : null;
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
final msg = (body is Map && body['error'] != null)
|
||||
? body['error'].toString()
|
||||
: 'HTTP ${res.statusCode}';
|
||||
throw ApiException(res.statusCode, msg);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/// Authenticates a user and stores the JWT. Returns the user email.
|
||||
Future<String> login(String email, String password) async {
|
||||
final res = await _http.post(
|
||||
_uri('/api/auth/login'),
|
||||
headers: _headers(null),
|
||||
body: jsonEncode({'email': email, 'password': password}),
|
||||
);
|
||||
final data = _decode(res) as Map<String, dynamic>;
|
||||
storage.jwt = data['access_token'] as String?;
|
||||
final user = data['user'] as Map<String, dynamic>?;
|
||||
storage.userEmail = user?['email'] as String?;
|
||||
return storage.userEmail ?? email;
|
||||
}
|
||||
|
||||
/// Registers this device and stores the returned device token.
|
||||
Future<void> registerDevice({
|
||||
required String deviceId,
|
||||
required String name,
|
||||
String platform = 'android',
|
||||
String appVersion = '1.0.0',
|
||||
String? pushToken,
|
||||
}) async {
|
||||
final res = await _http.post(
|
||||
_uri('/api/mobile/v1/device'),
|
||||
headers: _headers(storage.jwt),
|
||||
body: jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'name': name,
|
||||
'platform': platform,
|
||||
'app_version': appVersion,
|
||||
if (pushToken != null) 'push_token': pushToken,
|
||||
}),
|
||||
);
|
||||
final data = _decode(res) as Map<String, dynamic>;
|
||||
storage.deviceId = deviceId;
|
||||
storage.deviceName = name;
|
||||
storage.deviceToken = data['auth_token'] as String?;
|
||||
}
|
||||
|
||||
/// Pulls pending messages for this device (the server marks them Processed).
|
||||
Future<List<GatewayMessage>> pullMessages() async {
|
||||
final res = await _http.get(
|
||||
_uri('/api/mobile/v1/messages'),
|
||||
headers: _headers(storage.deviceToken),
|
||||
);
|
||||
final data = _decode(res) as Map<String, dynamic>;
|
||||
final items = (data['items'] as List<dynamic>? ?? []);
|
||||
return items
|
||||
.map((e) => GatewayMessage.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Reports a message's delivery state back to the server.
|
||||
Future<void> reportMessage(String id, String status, {String? error}) async {
|
||||
final res = await _http.patch(
|
||||
_uri('/api/mobile/v1/messages/$id'),
|
||||
headers: _headers(storage.deviceToken),
|
||||
body: jsonEncode({'status': status, if (error != null) 'error': error}),
|
||||
);
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// Posts an incoming SMS to the server's inbox.
|
||||
Future<void> postInbox(String phoneNumber, String message,
|
||||
{DateTime? receivedAt}) async {
|
||||
final res = await _http.post(
|
||||
_uri('/api/mobile/v1/inbox'),
|
||||
headers: _headers(storage.deviceToken),
|
||||
body: jsonEncode({
|
||||
'phone_number': phoneNumber,
|
||||
'message': message,
|
||||
if (receivedAt != null)
|
||||
'received_at': receivedAt.toUtc().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
_decode(res);
|
||||
}
|
||||
|
||||
/// Sends a heartbeat ping.
|
||||
Future<void> ping() async {
|
||||
final res = await _http.post(
|
||||
_uri('/api/mobile/v1/ping'),
|
||||
headers: _headers(storage.deviceToken),
|
||||
);
|
||||
_decode(res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import 'api_client.dart';
|
||||
import 'sms_service.dart';
|
||||
|
||||
/// A single line in the on-screen activity log.
|
||||
class LogEntry {
|
||||
final DateTime time;
|
||||
final String text;
|
||||
final bool error;
|
||||
LogEntry(this.text, {this.error = false}) : time = DateTime.now();
|
||||
}
|
||||
|
||||
/// Orchestrates the gateway loop: polls the API Server for pending messages,
|
||||
/// sends them over the radio, reports delivery state, forwards incoming SMS to
|
||||
/// the inbox, and sends heartbeats.
|
||||
///
|
||||
/// This runs in the foreground while the app is open. Moving the loop to a
|
||||
/// persistent background/foreground service (WorkManager) is a documented next
|
||||
/// step — see README.
|
||||
class GatewayService extends ChangeNotifier {
|
||||
final ApiClient api;
|
||||
final SmsService sms;
|
||||
|
||||
GatewayService(this.api, this.sms);
|
||||
|
||||
bool _running = false;
|
||||
bool get running => _running;
|
||||
|
||||
final List<LogEntry> _log = [];
|
||||
List<LogEntry> get log => List.unmodifiable(_log);
|
||||
|
||||
Timer? _pollTimer;
|
||||
Timer? _pingTimer;
|
||||
StreamSubscription<IncomingSms>? _incomingSub;
|
||||
StreamSubscription<SmsStatus>? _statusSub;
|
||||
bool _polling = false;
|
||||
|
||||
void start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_log0('Gateway started');
|
||||
|
||||
// Keep the process alive while the screen is off / app backgrounded.
|
||||
sms.startBackgroundService().catchError(
|
||||
(e) => _log0('Foreground service failed to start: $e', error: true));
|
||||
|
||||
_incomingSub = sms.incomingSms().listen(_onIncoming, onError: (e) {
|
||||
_log0('Incoming SMS stream error: $e', error: true);
|
||||
});
|
||||
_statusSub = sms.smsStatus().listen(_onStatus, onError: (e) {
|
||||
_log0('Status stream error: $e', error: true);
|
||||
});
|
||||
|
||||
_pollTimer = Timer.periodic(AppConfig.pollInterval, (_) => _poll());
|
||||
_pingTimer = Timer.periodic(AppConfig.pingInterval, (_) => _ping());
|
||||
_poll();
|
||||
_ping();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_running = false;
|
||||
_pollTimer?.cancel();
|
||||
_pingTimer?.cancel();
|
||||
_incomingSub?.cancel();
|
||||
_statusSub?.cancel();
|
||||
sms.stopBackgroundService().catchError((_) {});
|
||||
_log0('Gateway stopped');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (_polling || !_running) return;
|
||||
_polling = true;
|
||||
try {
|
||||
final messages = await api.pullMessages();
|
||||
for (final m in messages) {
|
||||
var handedOff = true;
|
||||
for (final phone in m.phoneNumbers) {
|
||||
try {
|
||||
if (m.isCall) {
|
||||
await sms.placeCall(phone);
|
||||
_log0('Calling $phone');
|
||||
} else {
|
||||
await sms.sendSms(phone, m.textMessage,
|
||||
simSlot: m.simNumber, messageId: m.id);
|
||||
_log0('Sending to $phone: "${_short(m.textMessage)}"');
|
||||
}
|
||||
} catch (e) {
|
||||
handedOff = false;
|
||||
await api.reportMessage(m.id, 'Failed', error: e.toString());
|
||||
_log0('${m.isCall ? "Call" : "Send"} to $phone failed: $e',
|
||||
error: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Report Sent once handed off. For SMS the native send/delivery
|
||||
// PendingIntents then refine this to Delivered (or Failed) via _onStatus;
|
||||
// a call has no delivery report, so it stays Sent.
|
||||
if (handedOff) await api.reportMessage(m.id, 'Sent');
|
||||
}
|
||||
} catch (e) {
|
||||
_log0('Poll error: $e', error: true);
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ping() async {
|
||||
if (!_running) return;
|
||||
try {
|
||||
await api.ping();
|
||||
} catch (e) {
|
||||
_log0('Ping failed: $e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStatus(SmsStatus s) async {
|
||||
final id = s.messageId;
|
||||
if (id == null || id.isEmpty) return;
|
||||
try {
|
||||
if (s.kind == 'delivered') {
|
||||
if (s.success) {
|
||||
await api.reportMessage(id, 'Delivered');
|
||||
_log0('Delivered: $id');
|
||||
} else {
|
||||
await api.reportMessage(id, 'Failed', error: 'delivery failed');
|
||||
_log0('Delivery failed: $id', error: true);
|
||||
}
|
||||
} else if (s.kind == 'sent' && !s.success) {
|
||||
await api.reportMessage(id, 'Failed', error: 'radio rejected message');
|
||||
_log0('Send rejected by radio: $id', error: true);
|
||||
}
|
||||
} catch (e) {
|
||||
_log0('Report status failed: $e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onIncoming(IncomingSms msg) async {
|
||||
_log0('Received from ${msg.from}: "${_short(msg.body)}"');
|
||||
try {
|
||||
await api.postInbox(msg.from, msg.body, receivedAt: msg.timestamp);
|
||||
} catch (e) {
|
||||
_log0('Forward inbox failed: $e', error: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _log0(String text, {bool error = false}) {
|
||||
_log.insert(0, LogEntry(text, error: error));
|
||||
if (_log.length > 100) _log.removeLast();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _short(String s) => s.length > 40 ? '${s.substring(0, 40)}…' : s;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stop();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// An incoming SMS delivered from the native side.
|
||||
class IncomingSms {
|
||||
final String from;
|
||||
final String body;
|
||||
final DateTime timestamp;
|
||||
IncomingSms(this.from, this.body, this.timestamp);
|
||||
}
|
||||
|
||||
/// A send/delivery status report for an outbound message.
|
||||
class SmsStatus {
|
||||
final String? messageId;
|
||||
final String kind; // 'sent' or 'delivered'
|
||||
final bool success;
|
||||
SmsStatus(this.messageId, this.kind, this.success);
|
||||
}
|
||||
|
||||
/// Bridges to the native Android side: SmsManager (sending), a BroadcastReceiver
|
||||
/// (incoming), send/delivery PendingIntents (status), and the foreground service
|
||||
/// that keeps the gateway alive. See android_overlay/ for the Kotlin side.
|
||||
class SmsService {
|
||||
static const _method = MethodChannel('app.gsmnode/sms');
|
||||
static const _events = EventChannel('app.gsmnode/sms_incoming');
|
||||
static const _status = EventChannel('app.gsmnode/sms_status');
|
||||
|
||||
/// Sends an SMS via the device radio. [simSlot] is 0-based; null = default SIM.
|
||||
/// [messageId] is echoed back on the status stream for correlation.
|
||||
/// Throws [PlatformException] on failure.
|
||||
Future<void> sendSms(String phoneNumber, String message,
|
||||
{int? simSlot, String? messageId}) async {
|
||||
await _method.invokeMethod('sendSms', {
|
||||
'phone': phoneNumber,
|
||||
'message': message,
|
||||
'simSlot': simSlot,
|
||||
'messageId': messageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Places a phone call to [phoneNumber] via the native dialer (ACTION_CALL).
|
||||
/// Requires the CALL_PHONE permission. Throws [PlatformException] on failure.
|
||||
Future<void> placeCall(String phoneNumber) async {
|
||||
await _method.invokeMethod('placeCall', {'phone': phoneNumber});
|
||||
}
|
||||
|
||||
/// Starts the foreground service so the gateway loop survives screen-off.
|
||||
Future<void> startBackgroundService() => _method.invokeMethod('startService');
|
||||
|
||||
/// Stops the foreground service.
|
||||
Future<void> stopBackgroundService() => _method.invokeMethod('stopService');
|
||||
|
||||
/// Stream of incoming SMS messages (active while the app process is alive).
|
||||
Stream<IncomingSms> incomingSms() {
|
||||
return _events.receiveBroadcastStream().map((event) {
|
||||
final map = Map<String, dynamic>.from(event as Map);
|
||||
final ts = map['timestamp'] as int?;
|
||||
return IncomingSms(
|
||||
map['from'] as String? ?? '',
|
||||
map['body'] as String? ?? '',
|
||||
ts != null ? DateTime.fromMillisecondsSinceEpoch(ts) : DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stream of send/delivery status reports for outbound messages.
|
||||
Stream<SmsStatus> smsStatus() {
|
||||
return _status.receiveBroadcastStream().map((event) {
|
||||
final map = Map<String, dynamic>.from(event as Map);
|
||||
return SmsStatus(
|
||||
map['messageId'] as String?,
|
||||
map['kind'] as String? ?? '',
|
||||
map['success'] == true,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persists connection settings and credentials across app launches.
|
||||
class Storage {
|
||||
static const _kApiBase = 'api_base';
|
||||
static const _kJwt = 'jwt';
|
||||
static const _kDeviceToken = 'device_token';
|
||||
static const _kDeviceId = 'device_id';
|
||||
static const _kDeviceName = 'device_name';
|
||||
static const _kUserEmail = 'user_email';
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
Storage(this._prefs);
|
||||
|
||||
static Future<Storage> create() async {
|
||||
return Storage(await SharedPreferences.getInstance());
|
||||
}
|
||||
|
||||
String? get apiBase => _prefs.getString(_kApiBase);
|
||||
set apiBase(String? v) => _set(_kApiBase, v);
|
||||
|
||||
String? get jwt => _prefs.getString(_kJwt);
|
||||
set jwt(String? v) => _set(_kJwt, v);
|
||||
|
||||
String? get deviceToken => _prefs.getString(_kDeviceToken);
|
||||
set deviceToken(String? v) => _set(_kDeviceToken, v);
|
||||
|
||||
String? get deviceId => _prefs.getString(_kDeviceId);
|
||||
set deviceId(String? v) => _set(_kDeviceId, v);
|
||||
|
||||
String? get deviceName => _prefs.getString(_kDeviceName);
|
||||
set deviceName(String? v) => _set(_kDeviceName, v);
|
||||
|
||||
String? get userEmail => _prefs.getString(_kUserEmail);
|
||||
set userEmail(String? v) => _set(_kUserEmail, v);
|
||||
|
||||
bool get isRegistered => (deviceToken ?? '').isNotEmpty;
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _prefs.remove(_kJwt);
|
||||
await _prefs.remove(_kDeviceToken);
|
||||
await _prefs.remove(_kUserEmail);
|
||||
// Keep device_id stable across logout so re-registering updates the same
|
||||
// device record instead of creating a new (phantom) one.
|
||||
}
|
||||
|
||||
void _set(String key, String? v) {
|
||||
if (v == null) {
|
||||
_prefs.remove(key);
|
||||
} else {
|
||||
_prefs.setString(key, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// gsmnode design tokens (see Design/SMS Gateway logo design/tokens in the
|
||||
/// project root). Brand is signal-green #2E9E6B on an ink/paper cool scale.
|
||||
abstract class GsmColors {
|
||||
// Signal green (brand) ramp
|
||||
static const green100 = Color(0xFFE4F4EC);
|
||||
static const green200 = Color(0xFFBEE6D0);
|
||||
static const green300 = Color(0xFF6BC79A); // lifted text on dark
|
||||
static const green500 = Color(0xFF2E9E6B); // primary
|
||||
static const green600 = Color(0xFF278A5C); // hover
|
||||
static const green700 = Color(0xFF1F6E49); // active
|
||||
static const greenOnDark = Color(0xFF4FB985); // accent on ink surfaces
|
||||
|
||||
// Ink & paper
|
||||
static const ink = Color(0xFF12161C); // primary text / dark surface
|
||||
static const ink900 = Color(0xFF0A0D11); // deepest surface
|
||||
static const ink800 = Color(0xFF1A1F27);
|
||||
static const paper = Color(0xFFFAFAF9); // page bg
|
||||
|
||||
// Cool gray ramp
|
||||
static const gray50 = Color(0xFFF4F5F4);
|
||||
static const gray100 = Color(0xFFE9EBEA);
|
||||
static const gray200 = Color(0xFFDCDFDE); // subtle border
|
||||
static const gray300 = Color(0xFFC2C7C6); // strong border
|
||||
static const gray400 = Color(0xFF9AA0A2); // muted text
|
||||
static const gray500 = Color(0xFF6B7278);
|
||||
static const gray600 = Color(0xFF4A5157);
|
||||
static const gray700 = Color(0xFF333A40); // secondary text
|
||||
|
||||
// Semantic (light)
|
||||
static const warning = Color(0xFFC68A2E);
|
||||
static const warningTint = Color(0xFFF6EAD6);
|
||||
static const danger = Color(0xFFC64A3E);
|
||||
static const dangerTint = Color(0xFFF6E1DC);
|
||||
|
||||
// Dark surfaces
|
||||
static const dBase = Color(0xFF0A0D11);
|
||||
static const dSunken = Color(0xFF0E1216);
|
||||
static const dCard = Color(0xFF14191F);
|
||||
static const dRaised = Color(0xFF1A2027);
|
||||
static const dBorderSubtle = Color(0xFF262B33);
|
||||
static const dBorderStrong = Color(0xFF363C46);
|
||||
static const dTextPrimary = Color(0xFFF5F6F4);
|
||||
static const dTextSecondary = Color(0xFFC2C7C6);
|
||||
static const dTextMuted = Color(0xFF9AA0A2);
|
||||
|
||||
// Semantic (dark — lifted for contrast)
|
||||
static const dWarning = Color(0xFFE0A84E);
|
||||
static const dDanger = Color(0xFFE0655B);
|
||||
}
|
||||
|
||||
/// Semantic role colors that flip between light and dark, exposed as a
|
||||
/// ThemeExtension so screens theme through roles, not raw ramp values.
|
||||
class GsmSemantic extends ThemeExtension<GsmSemantic> {
|
||||
const GsmSemantic({
|
||||
required this.pageBg,
|
||||
required this.sunkenBg,
|
||||
required this.card,
|
||||
required this.borderSubtle,
|
||||
required this.borderStrong,
|
||||
required this.textPrimary,
|
||||
required this.textSecondary,
|
||||
required this.textMuted,
|
||||
required this.brandTint,
|
||||
required this.success,
|
||||
required this.successTint,
|
||||
required this.warning,
|
||||
required this.warningTint,
|
||||
required this.danger,
|
||||
required this.dangerTint,
|
||||
});
|
||||
|
||||
final Color pageBg;
|
||||
final Color sunkenBg;
|
||||
final Color card;
|
||||
final Color borderSubtle;
|
||||
final Color borderStrong;
|
||||
final Color textPrimary;
|
||||
final Color textSecondary;
|
||||
final Color textMuted;
|
||||
final Color brandTint;
|
||||
final Color success;
|
||||
final Color successTint;
|
||||
final Color warning;
|
||||
final Color warningTint;
|
||||
final Color danger;
|
||||
final Color dangerTint;
|
||||
|
||||
static const light = GsmSemantic(
|
||||
pageBg: GsmColors.paper,
|
||||
sunkenBg: GsmColors.gray50,
|
||||
card: Colors.white,
|
||||
borderSubtle: GsmColors.gray200,
|
||||
borderStrong: GsmColors.gray300,
|
||||
textPrimary: GsmColors.ink,
|
||||
textSecondary: GsmColors.gray700,
|
||||
textMuted: GsmColors.gray500,
|
||||
brandTint: GsmColors.green100,
|
||||
success: GsmColors.green500,
|
||||
successTint: GsmColors.green100,
|
||||
warning: GsmColors.warning,
|
||||
warningTint: GsmColors.warningTint,
|
||||
danger: GsmColors.danger,
|
||||
dangerTint: GsmColors.dangerTint,
|
||||
);
|
||||
|
||||
static const dark = GsmSemantic(
|
||||
pageBg: GsmColors.dBase,
|
||||
sunkenBg: GsmColors.dSunken,
|
||||
card: GsmColors.dCard,
|
||||
borderSubtle: GsmColors.dBorderSubtle,
|
||||
borderStrong: GsmColors.dBorderStrong,
|
||||
textPrimary: GsmColors.dTextPrimary,
|
||||
textSecondary: GsmColors.dTextSecondary,
|
||||
textMuted: GsmColors.dTextMuted,
|
||||
brandTint: Color(0x292E9E6B), // green-500 @ 16%
|
||||
success: GsmColors.green300,
|
||||
successTint: Color(0x2E2E9E6B),
|
||||
warning: GsmColors.dWarning,
|
||||
warningTint: Color(0x2EC68A2E),
|
||||
danger: GsmColors.dDanger,
|
||||
dangerTint: Color(0x2EC64A3E),
|
||||
);
|
||||
|
||||
@override
|
||||
GsmSemantic copyWith({Color? pageBg}) => this;
|
||||
|
||||
@override
|
||||
GsmSemantic lerp(ThemeExtension<GsmSemantic>? other, double t) => this;
|
||||
}
|
||||
|
||||
extension GsmThemeX on BuildContext {
|
||||
GsmSemantic get cg => Theme.of(this).extension<GsmSemantic>()!;
|
||||
}
|
||||
|
||||
/// A mono (JetBrains Mono) style for IDs, numbers, timestamps and eyebrows.
|
||||
TextStyle gsmMono({
|
||||
double size = 12,
|
||||
Color? color,
|
||||
FontWeight weight = FontWeight.w500,
|
||||
double letterSpacing = 0,
|
||||
}) =>
|
||||
GoogleFonts.jetBrainsMono(
|
||||
fontSize: size,
|
||||
color: color,
|
||||
fontWeight: weight,
|
||||
letterSpacing: letterSpacing,
|
||||
);
|
||||
|
||||
/// Display (Space Grotesk) style for titles & metrics.
|
||||
TextStyle gsmDisplay({
|
||||
double size = 20,
|
||||
Color? color,
|
||||
FontWeight weight = FontWeight.w700,
|
||||
}) =>
|
||||
GoogleFonts.spaceGrotesk(
|
||||
fontSize: size,
|
||||
color: color,
|
||||
fontWeight: weight,
|
||||
letterSpacing: -0.02 * size,
|
||||
);
|
||||
|
||||
ThemeData _base(Brightness brightness, GsmSemantic s) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final scheme = ColorScheme(
|
||||
brightness: brightness,
|
||||
primary: GsmColors.green500,
|
||||
onPrimary: Colors.white,
|
||||
secondary: isDark ? GsmColors.greenOnDark : GsmColors.green600,
|
||||
onSecondary: Colors.white,
|
||||
error: s.danger,
|
||||
onError: Colors.white,
|
||||
surface: s.card,
|
||||
onSurface: s.textPrimary,
|
||||
outline: s.borderStrong,
|
||||
outlineVariant: s.borderSubtle,
|
||||
surfaceContainerLowest: s.sunkenBg,
|
||||
surfaceContainerLow: s.pageBg,
|
||||
surfaceContainer: s.card,
|
||||
);
|
||||
|
||||
// Body & UI in IBM Plex Sans; titles/metrics reach for Space Grotesk.
|
||||
final textTheme = GoogleFonts.ibmPlexSansTextTheme(
|
||||
(isDark ? ThemeData.dark() : ThemeData.light()).textTheme,
|
||||
).apply(bodyColor: s.textPrimary, displayColor: s.textPrimary);
|
||||
|
||||
const controlRadius = BorderRadius.all(Radius.circular(8));
|
||||
const cardRadius = BorderRadius.all(Radius.circular(18));
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: s.pageBg,
|
||||
textTheme: textTheme,
|
||||
extensions: [s],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: s.card,
|
||||
foregroundColor: s.textPrimary,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
shape: Border(bottom: BorderSide(color: s.borderSubtle)),
|
||||
titleTextStyle: GoogleFonts.spaceGrotesk(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: s.textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: s.card,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: cardRadius,
|
||||
side: BorderSide(color: s.borderSubtle),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: GsmColors.green500,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
|
||||
textStyle: GoogleFonts.ibmPlexSans(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).copyWith(
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.disabled)) {
|
||||
return GsmColors.green500.withValues(alpha: 0.45);
|
||||
}
|
||||
if (states.contains(WidgetState.pressed)) return GsmColors.green700;
|
||||
if (states.contains(WidgetState.hovered)) return GsmColors.green600;
|
||||
return GsmColors.green500;
|
||||
}),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: s.textPrimary,
|
||||
side: BorderSide(color: s.borderStrong),
|
||||
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
|
||||
textStyle: GoogleFonts.ibmPlexSans(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDark ? GsmColors.greenOnDark : GsmColors.green600,
|
||||
textStyle: GoogleFonts.ibmPlexSans(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: s.card,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: controlRadius,
|
||||
borderSide: BorderSide(color: s.borderStrong),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: controlRadius,
|
||||
borderSide: BorderSide(color: s.borderStrong),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: controlRadius,
|
||||
borderSide: BorderSide(color: GsmColors.green500, width: 2),
|
||||
),
|
||||
labelStyle: TextStyle(color: s.textSecondary),
|
||||
hintStyle: TextStyle(color: s.textMuted),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: s.borderSubtle, thickness: 1),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: isDark ? GsmColors.dRaised : GsmColors.ink,
|
||||
contentTextStyle: GoogleFonts.ibmPlexSans(color: Colors.white),
|
||||
shape: const RoundedRectangleBorder(borderRadius: controlRadius),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData gsmnodeLightTheme() => _base(Brightness.light, GsmSemantic.light);
|
||||
ThemeData gsmnodeDarkTheme() => _base(Brightness.dark, GsmSemantic.dark);
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// The gsmnode routing mark — two stacked arrows: the top (ink) routes right,
|
||||
/// the bottom (green) routes left. Geometry matches assets/svg/mark-color.svg
|
||||
/// (2.6px stroke on a 40px grid, rounded terminals).
|
||||
///
|
||||
/// When [color] is given the whole mark is drawn in that single color
|
||||
/// (e.g. white on a filled tile); otherwise the top arrow takes the surface
|
||||
/// ink color and the bottom arrow the signal green.
|
||||
class GsmNodeMark extends StatelessWidget {
|
||||
const GsmNodeMark({super.key, this.size = 32, this.color});
|
||||
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ink = color ?? Theme.of(context).colorScheme.onSurface;
|
||||
final green = color ?? GsmColors.green500;
|
||||
return CustomPaint(
|
||||
size: Size.square(size),
|
||||
painter: _MarkPainter(ink: ink, green: green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkPainter extends CustomPainter {
|
||||
_MarkPainter({required this.ink, required this.green});
|
||||
|
||||
final Color ink;
|
||||
final Color green;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final k = size.width / 40; // scale from the 40px design grid
|
||||
Paint stroke(Color c) => Paint()
|
||||
..color = c
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.6 * k
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
final inkPaint = stroke(ink);
|
||||
final greenPaint = stroke(green);
|
||||
|
||||
// Top arrow — routes right (ink).
|
||||
canvas.drawLine(Offset(7 * k, 15 * k), Offset(31 * k, 15 * k), inkPaint);
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(26 * k, 10 * k)
|
||||
..lineTo(32 * k, 15 * k)
|
||||
..lineTo(26 * k, 20 * k),
|
||||
inkPaint,
|
||||
);
|
||||
|
||||
// Bottom arrow — routes left (green).
|
||||
canvas.drawLine(Offset(9 * k, 25 * k), Offset(33 * k, 25 * k), greenPaint);
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(14 * k, 20 * k)
|
||||
..lineTo(8 * k, 25 * k)
|
||||
..lineTo(14 * k, 30 * k),
|
||||
greenPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_MarkPainter old) => old.ink != ink || old.green != green;
|
||||
}
|
||||
|
||||
/// The gsmnode wordmark: lowercase `gsm` in ink + `node` in green, monospace.
|
||||
/// On dark surfaces `node` lifts to the on-dark green. Pass [color] to render
|
||||
/// the whole wordmark in a single color.
|
||||
class GsmNodeWordmark extends StatelessWidget {
|
||||
const GsmNodeWordmark({super.key, this.size = 24, this.color});
|
||||
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final ink = color ?? Theme.of(context).colorScheme.onSurface;
|
||||
final green =
|
||||
color ?? (isDark ? GsmColors.greenOnDark : GsmColors.green500);
|
||||
return Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(text: 'gsm', style: TextStyle(color: ink)),
|
||||
TextSpan(text: 'node', style: TextStyle(color: green)),
|
||||
]),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.01 * size,
|
||||
height: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user