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:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+15
View File
@@ -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);
}
+42
View File
@@ -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(),
);
}
}
+33
View File
@@ -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? ?? '',
);
}
}
+298
View File
@@ -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),
),
],
),
);
},
);
}
}
+152
View File
@@ -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'),
),
],
),
),
),
),
);
}
}
+130
View File
@@ -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);
}
}
+165
View File
@@ -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();
}
}
+76
View File
@@ -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,
);
});
}
}
+54
View File
@@ -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);
}
}
}
+292
View File
@@ -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);
+102
View File
@@ -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,
),
);
}
}