Files
saat-app/flutter/lib/home_screen.dart
Alex Ardhian 5ab72678a3 Gabung app Android Flutter 'Saat Invest' ke repo saat-app (folder flutter/)
Satu repo untuk backend Odoo (addon saat_invest) + app Android.
- flutter/ berisi project Flutter (lib/, android/, pubspec, test)
- Login Odoo JSON-RPC, tab Portofolio+Equity, tab Transaksi, catat jual/beli otomatis sync
- Build .gitignore nested dipakai sehingga artefak build/.dart_tool tak ter-track
Sejarah: gabung flat (satu sejarah backend+app).
2026-09-02 14:48:32 +07:00

300 lines
9.9 KiB
Dart

import 'package:flutter/material.dart';
import '../api/odoo_client.dart';
import '../models/portfolio.dart';
import '../models/saham_transaksi.dart';
import '../services/stock_service.dart';
import 'add_transaksi_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
required this.baseUrl,
required this.db,
required this.user,
required this.pass,
required this.onLogout,
});
final String baseUrl;
final String db;
final String user;
final String pass;
final VoidCallback onLogout;
@override
Widget build(BuildContext context) {
final client = OdooClient(baseUrl: baseUrl, db: db, username: user, password: pass);
final service = StockService(client);
return _HomeShell(
service: service,
client: client,
title: 'Portofolio',
onLogout: onLogout,
);
}
}
class _HomeShell extends StatefulWidget {
const _HomeShell({required this.service, required this.client, required this.title, required this.onLogout});
final StockService service;
final OdooClient client;
final String title;
final VoidCallback onLogout;
@override
State<_HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<_HomeShell> {
int _tab = 0;
int _reloadTick = 0;
void _reload() => setState(() => _reloadTick++);
Future<void> _openAdd() async {
final added = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => AddTransaksiScreen(service: widget.service),
),
);
if (added == true) _reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Saat Invest'),
actions: [
IconButton(icon: const Icon(Icons.refresh), tooltip: 'Muat ulang',
onPressed: _reload),
IconButton(icon: const Icon(Icons.logout), tooltip: 'Keluar', onPressed: widget.onLogout),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _openAdd,
tooltip: 'Catat transaksi baru',
child: const Icon(Icons.add),
),
body: IndexedStack(
index: _tab,
children: [
_PortfolioTab(key: ValueKey('pf$_reloadTick'), service: widget.service),
_TransaksiTab(key: ValueKey('tx$_reloadTick'), service: widget.service),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.pie_chart), label: 'Portofolio'),
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Transaksi'),
],
),
);
}
}
// ---------------- Portofolio / Equity tab ----------------
class _PortfolioTab extends StatefulWidget {
const _PortfolioTab({super.key, required this.service});
final StockService service;
@override
State<_PortfolioTab> createState() => _PortfolioTabState();
}
class _PortfolioTabState extends State<_PortfolioTab> {
late Future<List<Portfolio>> _fut;
@override
void initState() {
super.initState();
_fut = widget.service.listPortofolio();
}
void _reload() => setState(() => _fut = widget.service.listPortofolio());
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Portfolio>>(
future: _fut,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorRetry(message: '${snap.error}', onRetry: _reload);
}
final list = snap.data ?? [];
final totalNilai = list.fold<double>(
0, (a, p) => a + p.nilaiSekarang);
final totalModal = list.fold<double>(0, (a, p) => a + p.avgCost * p.sisaLembar);
final totalPl = list.fold<double>(0, (a, p) => a + p.unrealizedPl + p.realizedPl);
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView(
children: [
_EquityCard(equity: totalNilai, modal: totalModal, pl: totalPl),
if (list.isEmpty)
const Padding(
padding: EdgeInsets.all(32),
child: Text('Belum ada posisi.\nCatat transaksi beli dulu di tab Transaksi.',
textAlign: TextAlign.center),
),
...list.map((p) => Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: ListTile(
title: Text(p.sahamTicker, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${p.sisaLembar} lbr · avg ${_fmt(p.avgCost)} · hrga ${_fmt(p.hargaSekarang)}'),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_fmt(p.nilaiSekarang)),
Text(_rm(p.unrealizedPl), style: TextStyle(
color: p.unrealizedPl >= 0 ? Colors.green : Colors.red)),
],
),
),
)),
const SizedBox(height: 16),
],
),
);
},
);
}
}
class _EquityCard extends StatelessWidget {
const _EquityCard({required this.equity, required this.modal, required this.pl});
final double equity;
final double modal;
final double pl;
@override
Widget build(BuildContext context) {
final pct = modal > 0 ? (pl / modal) * 100 : 0.0;
return Card(
margin: const EdgeInsets.all(12),
color: const Color(0xFF0B6E4F),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Total Equity', style: TextStyle(color: Colors.white70, fontSize: 14)),
Text('Rp ${_fmt(equity, force: true)}',
style: const TextStyle(color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Modal dikeluarkan: ${_fmt(modal)}',
style: const TextStyle(color: Colors.white70)),
Row(children: [
Text('P/L: ', style: const TextStyle(color: Colors.white70)),
Text(_rm(pl),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('(${pct.toStringAsFixed(2)}%)', style: const TextStyle(color: Colors.white70)),
]),
]),
),
);
}
}
// ---------------- Transaksi tab ----------------
class _TransaksiTab extends StatefulWidget {
const _TransaksiTab({super.key, required this.service});
final StockService service;
@override
State<_TransaksiTab> createState() => _TransaksiTabState();
}
class _TransaksiTabState extends State<_TransaksiTab> {
late Future<List<Transaksi>> _fut;
@override
void initState() {
super.initState();
_fut = widget.service.listTransaksi();
}
void _reload() => setState(() => _fut = widget.service.listTransaksi());
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Transaksi>>(
future: _fut,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return _ErrorRetry(message: '${snap.error}', onRetry: _reload);
}
final list = snap.data ?? [];
return Column(children: [
Expanded(
child: list.isEmpty
? const Center(child: Text('Belum ada transaksi.'))
: RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
itemCount: list.length,
itemBuilder: (c, i) {
final t = list[i];
final buy = t.isBuy;
return ListTile(
leading: CircleAvatar(
backgroundColor: buy ? Colors.green : Colors.red,
child: Text(buy ? 'B' : 'J', style: const TextStyle(color: Colors.white)),
),
title: Text('${t.sahamTicker} · ${t.volume} lbr'),
subtitle: Text('${t.tanggal} @ ${_fmt(t.harga)} · fee ${_fmt(t.fee)}'),
trailing: Text(_fmt(t.total)),
);
},
),
),
),
]);
},
);
}
}
class _ErrorRetry extends StatelessWidget {
const _ErrorRetry({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.error_outline, color: Colors.red, size: 40),
const SizedBox(height: 8),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton(onPressed: onRetry, child: const Text('Coba lagi')),
]),
),
);
}
// helpers
String _fmt(double v, {bool force = false}) {
final fmt = force
? v.toStringAsFixed(0).replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.')
: v.toStringAsFixed(0).replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
return fmt;
}
String _rm(double v) =>
'${v >= 0 ? '+' : ''}Rp ${_formatAbs(v)}';
String _formatAbs(double v) {
final a = v.abs().toStringAsFixed(0);
return a.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
}