diff --git a/flutter/lib/edit_saham_screen.dart b/flutter/lib/edit_saham_screen.dart new file mode 100644 index 0000000..947f41a --- /dev/null +++ b/flutter/lib/edit_saham_screen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import '../models/saham_transaksi.dart'; +import '../services/stock_service.dart'; + +class EditSahamScreen extends StatefulWidget { + const EditSahamScreen({super.key, required this.service, this.saham}); + final StockService service; + final Saham? saham; // null = tambah baru; ada = edit + @override + State createState() => _EditSahamScreenState(); +} + +class _EditSahamScreenState extends State { + final _kode = TextEditingController(); + final _perusahaan = TextEditingController(); + final _sektor = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + final s = widget.saham; + if (s != null) { + _kode.text = s.kode; + _perusahaan.text = s.perusahaan ?? ''; + _sektor.text = s.sektor ?? ''; + } else { + _sektor.text = 'Financial'; + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.saham != null; + return Scaffold( + appBar: AppBar(title: Text(isEdit ? 'Edit Saham' : 'Tambah Saham')), + body: Padding( + padding: const EdgeInsets.all(16), + child: ListView( + children: [ + TextField( + controller: _kode, + enabled: !isEdit, // kode tidak boleh diubah saat edit + decoration: const InputDecoration( + labelText: 'Kode Saham (Ticker)', + hintText: 'misal: BBRI, UNVR, TLKM', + prefixIcon: Icon(Icons.business)), + ), + const SizedBox(height: 12), + TextField( + controller: _perusahaan, + decoration: const InputDecoration( + labelText: 'Nama Perusahaan / Emiten', + prefixIcon: Icon(Icons.apartment)), + ), + const SizedBox(height: 12), + TextField( + controller: _sektor, + decoration: const InputDecoration( + labelText: 'Sektor', prefixIcon: Icon(Icons.category)), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(color: Colors.red)), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _busy ? null : _save, + icon: _busy + ? const SizedBox(width: 18, height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.save), + label: const Text('Simpan'), + ), + ], + ), + ), + ); + } + + Future _save() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final kode = _kode.text.trim().toUpperCase(); + if (kode.isEmpty) throw Exception('Kode saham wajib diisi'); + + if (widget.saham == null) { + await widget.service.addSaham( + kode: kode, + perusahaan: _perusahaan.text.trim(), + sektor: _sektor.text.trim(), + ); + } else { + await widget.service.updateSaham(widget.saham!.id, + perusahaan: _perusahaan.text.trim(), + sektor: _sektor.text.trim(), + ); + } + if (!mounted) return; + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } +} diff --git a/flutter/lib/home_screen.dart b/flutter/lib/home_screen.dart index ea119e6..5bc8549 100644 --- a/flutter/lib/home_screen.dart +++ b/flutter/lib/home_screen.dart @@ -5,6 +5,7 @@ import '../models/portfolio.dart'; import '../models/saham_transaksi.dart'; import '../services/stock_service.dart'; import 'add_transaksi_screen.dart'; +import 'edit_saham_screen.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({ @@ -24,7 +25,8 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final client = OdooClient(baseUrl: baseUrl, db: db, username: user, password: pass); + final client = OdooClient( + baseUrl: baseUrl, db: db, username: user, password: pass); final service = StockService(client); return _HomeShell( service: service, @@ -53,12 +55,23 @@ class _HomeShellState extends State<_HomeShell> { void _reload() => setState(() => _reloadTick++); Future _openAdd() async { - final added = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => AddTransaksiScreen(service: widget.service), - ), - ); - if (added == true) _reload(); + if (_tab == 1) { + // Tab Saham -> buka tambah saham + final added = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditSahamScreen(service: widget.service), + ), + ); + if (added == true) _reload(); + } else { + // Tab lain -> buka buat transaksi + final added = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AddTransaksiScreen(service: widget.service), + ), + ); + if (added == true) _reload(); + } } @override @@ -67,20 +80,26 @@ class _HomeShellState extends State<_HomeShell> { appBar: AppBar( title: const Text('Saat Invest'), actions: [ - IconButton(icon: const Icon(Icons.refresh), tooltip: 'Muat ulang', + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Muat ulang', onPressed: _reload), - IconButton(icon: const Icon(Icons.logout), tooltip: 'Keluar', onPressed: widget.onLogout), + IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Keluar', + onPressed: widget.onLogout), ], ), floatingActionButton: FloatingActionButton( onPressed: _openAdd, - tooltip: 'Catat transaksi baru', - child: const Icon(Icons.add), + tooltip: _tab == 1 ? 'Tambah Saham' : 'Catat Transaksi', + child: Icon(_tab == 1 ? Icons.add_business : Icons.add), ), body: IndexedStack( index: _tab, children: [ _PortfolioTab(key: ValueKey('pf$_reloadTick'), service: widget.service), + _SahamTab(key: ValueKey('sh$_reloadTick'), service: widget.service), _TransaksiTab(key: ValueKey('tx$_reloadTick'), service: widget.service), ], ), @@ -89,6 +108,7 @@ class _HomeShellState extends State<_HomeShell> { onDestinationSelected: (i) => setState(() => _tab = i), destinations: const [ NavigationDestination(icon: Icon(Icons.pie_chart), label: 'Portofolio'), + NavigationDestination(icon: Icon(Icons.trending_up), label: 'Saham'), NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Transaksi'), ], ), @@ -96,7 +116,7 @@ class _HomeShellState extends State<_HomeShell> { } } -// ---------------- Portofolio / Equity tab ---------------- +// ---------------- Portofolio tab ---------------- class _PortfolioTab extends StatefulWidget { const _PortfolioTab({super.key, required this.service}); @@ -107,7 +127,6 @@ class _PortfolioTab extends StatefulWidget { class _PortfolioTabState extends State<_PortfolioTab> { late Future> _fut; - @override void initState() { super.initState(); @@ -128,8 +147,7 @@ class _PortfolioTabState extends State<_PortfolioTab> { return _ErrorRetry(message: '${snap.error}', onRetry: _reload); } final list = snap.data ?? []; - final totalNilai = list.fold( - 0, (a, p) => a + p.nilaiSekarang); + final totalNilai = list.fold(0, (a, p) => a + p.nilaiSekarang); final totalModal = list.fold(0, (a, p) => a + p.avgCost * p.sisaLembar); final totalPl = list.fold(0, (a, p) => a + p.unrealizedPl + p.realizedPl); return RefreshIndicator( @@ -144,21 +162,24 @@ class _PortfolioTabState extends State<_PortfolioTab> { 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)), - ], - ), - ), - )), + 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), ], ), @@ -181,23 +202,85 @@ class _EquityCard extends StatelessWidget { 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)), - ]), - ]), - ), + 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)), + ]), + ])), + ); + } +} + +// ---------------- Saham tab ---------------- + +class _SahamTab extends StatefulWidget { + const _SahamTab({super.key, required this.service}); + final StockService service; + @override + State<_SahamTab> createState() => _SahamTabState(); +} + +class _SahamTabState extends State<_SahamTab> { + late Future> _fut; + @override + void initState() { + super.initState(); + _fut = widget.service.listSaham(); + } + + void _reload() => setState(() => _fut = widget.service.listSaham()); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + 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 RefreshIndicator( + onRefresh: () async => _reload(), + child: ListView.builder( + itemCount: list.length, + itemBuilder: (c, i) { + final s = list[i]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF0B6E4F), + child: Text(s.kode.substring(0, 1).toUpperCase(), + style: const TextStyle(color: Colors.white)), + ), + title: Text('${s.kode} — ${s.perusahaan ?? '-'}'), + subtitle: Text(s.sektor ?? 'Sektor: -'), + ), + ); + }, + ), + ); + }, ); } } @@ -213,7 +296,6 @@ class _TransaksiTab extends StatefulWidget { class _TransaksiTabState extends State<_TransaksiTab> { late Future> _fut; - @override void initState() { super.initState(); @@ -247,9 +329,10 @@ class _TransaksiTabState extends State<_TransaksiTab> { 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)), - ), + 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)), @@ -271,23 +354,24 @@ class _ErrorRetry extends StatelessWidget { @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')), - ]), - ), + 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) => '.'); + : v.toStringAsFixed(0).replaceAllMapped( + RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.'); return fmt; } @@ -296,5 +380,4 @@ String _rm(double v) => String _formatAbs(double v) { final a = v.abs().toStringAsFixed(0); return a.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.'); -} - +} \ No newline at end of file diff --git a/flutter/lib/services/stock_service.dart b/flutter/lib/services/stock_service.dart index 72577fb..e92d161 100644 --- a/flutter/lib/services/stock_service.dart +++ b/flutter/lib/services/stock_service.dart @@ -15,6 +15,35 @@ class StockService { return (res as List).map((e) => Saham.fromOdoo(e as Map)).toList(); } + // Tambah saham baru ke Odoo saat.saham.create + Future addSaham({ + required String kode, + String? perusahaan, + String? sektor, + }) async { + final vals = { + 'name': kode, + if (perusahaan != null && perusahaan.isNotEmpty) 'perusahaan': perusahaan, + if (sektor != null && sektor.isNotEmpty) 'sector': sektor, + }; + final res = await client.executeKwd('saat.saham', 'create', args: [vals]); + return (res as num).toInt(); + } + + // Update data saham (perusahaan, sektor) + Future updateSaham(int id, {String? perusahaan, String? sektor}) async { + final vals = { + if (perusahaan != null) 'perusahaan': perusahaan, + if (sektor != null) 'sector': sektor, + }; + await client.executeKwd('saat.saham', 'write', args: [[id], vals]); + } + + // --- Hapus saham (optional) --- + Future hapusSaham(int id) async { + await client.executeKwd('saat.saham', 'unlink', args: [[id]]); + } + // ---- Portofolio ---- Future> listPortofolio() async { final fields = [