parent
690b86d010
commit
cdb1a4c860
|
|
@ -0,0 +1,261 @@
|
|||
<mxfile host="65bd71144e">
|
||||
<diagram id="xo71m8-ne6otC44RO2IR" name="Page-1">
|
||||
<mxGraphModel dx="-14" dy="483" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="2" value="<font style="font-size: 21px;">TRUE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1730.78" y="1900" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="5">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1724.56" y="1950.38" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="4" style="edgeStyle=none;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="5" target="57">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="2002" y="1820"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="<div><font style="font-size: 21px;"><br></font></div><font style="font-size: 21px;">EMAIL,</font><div><font style="font-size: 21px;">PASSWORD,</font></div><div><font style="font-size: 21px;">DEVICE ID</font><div><font style="font-size: 21px;">BENAR?</font><div><br></div></div></div>" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1614.56" y="1730" width="220" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="10" target="36">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="<font style="font-size: 21px;"><br>USER INPUT<br>EMAIL, PASSWORD DEVICE ID YANG SESUAI</font>" style="shape=manualInput;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1595.15" y="1430" width="258.82" height="110" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="12" target="78">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="<font style="font-size: 21px;">MEMPROSES KONVERSI RENTANG JADWAL KE&nbsp;</font><span style="color: rgb(192, 192, 192); font-size: 21px;">MIKROSEKON</span>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1295.16" y="1445.09" width="253.01" height="79.82" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="14" target="53">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="<font style="font-size: 21px;">MEMPROSES PENGIRIMAN NILAI MIKROSEKON</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1309.02" y="1620" width="226.37" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="16" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" value="<font style="font-size: 21px;"><br>USER MELAKUKAN SET TIMER UNTUK JADWAL AKTIF ALAT</font>" style="shape=manualInput;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1305.39" y="1290" width="233.12" height="124.82" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="34" value="<font style="font-size: 21px;">FALSE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1808.6799999999998" y="1770" width="90" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="35" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="36" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="36" value="<font style="font-size: 21px;">CLOUD AUTH DAN DATABASE</font>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1597" y="1560" width="255" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="37" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="39" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="38" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="39" target="44">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="39" value="<font style="font-size: 21px;">SUDAH</font><div><font style="font-size: 21px;">MEMILIKI</font></div><div><font style="font-size: 21px;">AKUN DAN</font></div><div><font style="font-size: 21px;">DEVICE ID?</font></div>" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1614.5" y="1210" width="220" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="40" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="41" target="39">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="41" value="<span style="background-color: transparent;"><font style="font-size: 21px;">FORM LOGIN:</font></span><div><span style="background-color: transparent; font-size: 21px;">- KOLOM INPUT EMAIL</span><div style="scrollbar-color: light-dark(#e2e2e2, #4b4b4b)<br/>					light-dark(#fbfbfb, var(--dark-panel-color));"><span style="scrollbar-color: light-dark(#e2e2e2, #4b4b4b)<br/>					light-dark(#fbfbfb, var(--dark-panel-color)); background-color: transparent; font-size: 21px;">- KOLOM INPUT&nbsp;</span><span style="background-color: transparent; font-size: 21px;">PASSWORD</span></div></div><div style="scrollbar-color: light-dark(#e2e2e2, #4b4b4b)<br/>					light-dark(#fbfbfb, var(--dark-panel-color));"><span style="background-color: transparent; font-size: 21px;">- KOLOM INPUT DEVICE ID</span></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1558.28" y="907.08" width="330" height="122.92" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="42" value="<font style="font-size: 21px;">TRUE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1638.78" y="1400" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="43" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="44" target="70">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="44" value="<font style="font-size: 21px;">USER KLIK REGISTER UNTUK MENUJU&nbsp; KE HALAMAN REGISTER</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1907.4599999999998" y="1259.69" width="243.31" height="86.56" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="45" value="" style="edgeStyle=none;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="46" target="41">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="1907.4599999999998" y="849.72" as="sourcePoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="46" value="<div><font style="font-size: 21px;">NOTIFIKASI: "Pendaftaran berhasil."</font></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1903.24" y="941.18" width="251.74" height="54.72" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="47" value="<font style="font-size: 21px;">FALSE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1808.68" y="1320" width="90" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="48" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="49" target="41">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="49" value="<font style="font-size: 21px;">USER MASUK KEDALAM APLIKASI</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1618.98" y="820" width="208.6" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="50" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="51" target="49">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="51" value="<font style="font-size: 21px;">START</font>" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1655.78" y="730" width="135" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="52" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="53" target="73">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="53" value="<font style="font-size: 21px;">CLOUD</font><div><font style="font-size: 21px;">DATABASE</font></div>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1294.17" y="1730" width="255" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="54" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="55" target="60">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="55" value="<font style="font-size: 36px;">A</font>" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1381.95" y="780" width="80" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="56" style="edgeStyle=none;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="57" target="10">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="2002" y="1485"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="57" value="<div><font style="font-size: 21px;">NOTIFIKASI:</font></div><div><font style="font-size: 21px;">"Login gagal. Masukkan email, password dan</font></div><div><font style="font-size: 21px;">device id yang benar"</font></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1870" y="1590" width="264.75" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="58" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1" source="60" target="62">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="59" style="edgeStyle=none;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="60" target="73">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1255" y="1010"/>
|
||||
<mxPoint x="1255" y="1790"/>
|
||||
<mxPoint x="1255" y="1930"/>
|
||||
</Array>
|
||||
<mxPoint x="1381.9499999999998" y="1870.81" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="60" value="<div><span style="background-color: transparent;"><font style="font-size: 21px;">INGIN&nbsp;</font></span></div><div><span style="background-color: transparent;"><font style="font-size: 21px;">SET</font></span></div><div><span style="background-color: transparent;"><font style="font-size: 21px;">TIMER?</font></span></div>" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1330.16" y="930" width="183.05" height="160" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="61" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="62" target="76">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="62" value="<font style="font-size: 21px;">USER KLIK TIMER UNTUK MASUK KE FITUR SET TIMER</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1324.05" y="1120" width="196.32" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="63" value="<font style="font-size: 21px;">TRUE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1444.85" y="1068.63" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="64" value="<font style="font-size: 21px;">FALSE</font>" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="1">
|
||||
<mxGeometry x="1250.19" y="1010" width="90" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="69" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="70" target="72">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="70" value="<div><font style="font-size: 21px;">FORM&nbsp;<span style="background-color: transparent;">REGISTER:</span></font></div><div><span style="background-color: transparent; font-size: 21px;">- KOLOM INPUT EMAIL</span></div><div><span style="background-color: transparent; font-size: 21px;">- KOLOM INPUT PASSWORD</span></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1838.11" y="1140" width="377.04" height="90" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="71" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="72" target="46">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="72" value="<font style="font-size: 21px;">USER MEMASUKAN EMAIL DAN PASSWORD KE FORM REGISTER</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1905.3899999999999" y="1030" width="247.46" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="73" value="<font style="font-size: 21px;">END</font>" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1354.17" y="1900.25" width="135" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="75" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="76" target="16">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="76" value="<div><span style="font-size: 21px; background-color: transparent;">SET TIMER</span><br></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1316.68" y="1230" width="210" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="77" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="78" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="78" value="<div><span style="font-size: 21px;">NILAI&nbsp;</span><span style="color: rgb(192, 192, 192); font-size: 21px; background-color: transparent;">MIKROSEKON</span></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1300.54" y="1550" width="243.33" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="104" value="" style="edgeStyle=none;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="106">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="2249.3100000000013" y="3920.25" as="targetPoint"/>
|
||||
<Array as="points">
|
||||
<mxPoint x="2295" y="4075"/>
|
||||
<mxPoint x="2295" y="3920"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="303" value="" style="edgeStyle=none;html=1;" edge="1" source="304" target="306" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="321" value="" style="edgeStyle=none;html=1;" edge="1" parent="1" source="304" target="320">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="304" value="<font style="font-size: 21px;">DASHBOARD:</font><div><font style="font-size: 21px;">- TIMER DAN STATUS</font></div><div><span style="font-size: 21px; background-color: transparent;">- GALERI FOOTAGE</span></div><div><span style="font-size: 21px; background-color: transparent;">-&nbsp;</span><span style="color: rgb(192, 192, 192); font-size: 21px; background-color: transparent;">EVENT DAN&nbsp;</span><span style="background-color: transparent; font-size: 21px;">TIMESTAMP&nbsp;</span></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1563.7300000000005" y="1950" width="312.35" height="130" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="305" style="edgeStyle=none;html=1;" edge="1" source="306" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="2166.63" y="2180" as="targetPoint"/>
|
||||
<Array as="points">
|
||||
<mxPoint x="2206.63" y="2015"/>
|
||||
<mxPoint x="2206.63" y="2180"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="306" value="<font style="font-size: 21px;">STATUS, </font><span style="font-size: 21px; background-color: transparent;">FOOTAGE,</span><div><span style="font-size: 21px; background-color: transparent;">EVENT DAN TIMESTAMP</span></div>" style="shape=display;whiteSpace=wrap;html=1;size=0.1586317963799327;" vertex="1" parent="1">
|
||||
<mxGeometry x="1899.9399999999996" y="1972.25" width="285.63" height="85.5" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="307" value="" style="edgeStyle=none;html=1;" edge="1" source="309" target="306" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="308" value="" style="edgeStyle=none;html=1;" edge="1" source="309" target="312" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="309" value="<font style="font-size: 21px;">CLOUD</font><div><font style="font-size: 21px;">DATABASE</font></div>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1915.25" y="2110" width="255" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="310" value="" style="edgeStyle=none;html=1;" edge="1" source="312" target="314" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="311" value="" style="edgeStyle=none;html=1;" edge="1" source="312" target="319" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="312" value="<span style="font-size: 21px;">APAKAH ADA</span><div><span style="font-size: 21px;">LINK FOTO</span></div><div><span style="font-size: 21px;">BARU?</span></div>" style="rhombus;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1609.9099999999999" y="2095" width="220" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="313" value="" style="edgeStyle=none;html=1;" edge="1" source="314" target="316" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="314" value="<font style="font-size: 21px;">JALANKAN EDGE FUNCTION</font>" style="whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1396.63" y="2147.34" width="190" height="75.31" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="315" style="edgeStyle=none;html=1;exitX=0.55;exitY=0.95;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" source="316" target="318" parent="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="1251.48" y="2350"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="316" value="<span style="font-size: 21px;">RESEND</span><br><div><font style="font-size: 21px;">MESSAGING</font></div>" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1110" y="2110" width="255" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="317" value="" style="edgeStyle=none;html=1;" edge="1" source="318" target="319" parent="1">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="318" value="<font style="font-size: 21px;">NOTIFIKASI:</font><div><font style="font-size: 21px;">"Perhatian ada</font></div><div><font style="font-size: 21px;">ancaman terbaru."</font></div>" style="shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;fixedSize=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1355.8600000000001" y="2307.5" width="240.94" height="82.5" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="319" value="<font style="font-size: 21px;">END</font>" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1655.73" y="2318.75" width="135" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="320" value="<font style="font-size: 36px;">A</font>" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="1422.25" y="1977.75" width="80" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
SUPABASE_URL=https://gtpvcrpnojbjzoedqemy.supabase.co
|
||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd0cHZjcnBub2pianpvZWRxZW15Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEwMDY2NjMsImV4cCI6MjA1NjU4MjY2M30.PDDMcfnpVtZS7FTYi-qXejTS10uGKPmwzrcOXhutUsk
|
||||
SUPABASE_sECRET_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd0cHZjcnBub2pianpvZWRxZW15Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEwMDY2NjMsImV4cCI6MjA1NjU4MjY2M30.PDDMcfnpVtZS7FTYi-qXejTS10uGKPmwzrcOXhutUsk
|
||||
SUPABASE_URL=https://ihuetazxejsioxcjejpj.supabase.co
|
||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlodWV0YXp4ZWpzaW94Y2planBqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExMDEyMTgsImV4cCI6MjA2NjY3NzIxOH0.IKFyrKioiScfAS_9UouPEcesHHoas0SDZH0mZBCA1ro
|
||||
|
||||
|
|
|
|||
|
|
@ -14,3 +14,109 @@ A few resources to get you started if this is your first Flutter project:
|
|||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
||||
----------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
Penjelasan Detail per Bagian Kode
|
||||
1. main.dart
|
||||
Ini adalah titik masuk (entry point) dari aplikasi Anda.
|
||||
|
||||
Future<void> main() async: Fungsi main dibuat async karena kita perlu menunggu proses inisialisasi selesai sebelum aplikasi berjalan.
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized(): Baris ini wajib ada ketika Anda ingin menjalankan kode sebelum runApp(), memastikan semua binding Flutter siap.
|
||||
|
||||
await dotenv.load(...): Memuat semua variabel dari file .env Anda (seperti URL dan Kunci Supabase) ke dalam memori.
|
||||
|
||||
await Supabase.initialize(...): Menginisialisasi koneksi ke proyek Supabase Anda. Ini adalah langkah krusial.
|
||||
|
||||
ChangeNotifierProvider(...): Ini berasal dari library provider. Ini "membungkus" seluruh aplikasi Anda dengan AppState, sehingga semua halaman di bawahnya dapat "mendengarkan" dan bereaksi terhadap perubahan data di AppState.
|
||||
|
||||
MyApp: Widget utama yang mengatur tema global aplikasi dan mendefinisikan halaman awal.
|
||||
|
||||
2. AppState (lib/provider/app_state.dart)
|
||||
Ini adalah "otak" dari aplikasi Anda. Kelas ini mengelola semua data dan logika bisnis aplikasi (state management).
|
||||
|
||||
class AppState extends ChangeNotifier: ChangeNotifier adalah kelas dari Flutter yang memungkinkan AppState untuk "memberi tahu" para pendengarnya (widget) ketika ada data yang berubah, menggunakan notifyListeners().
|
||||
|
||||
Variabel State (_): Variabel yang diawali dengan _ (contoh: _isLoading) bersifat private. Data ini hanya bisa diubah dari dalam kelas AppState itu sendiri. Widget lain hanya bisa membacanya melalui getters (contoh: isLoading).
|
||||
|
||||
Constructor AppState(): Di sini kita mengatur pendengar onAuthStateChange. Ini adalah pendengar realtime dari Supabase yang aktif setiap kali ada perubahan status login (login, logout). Jika pengguna logout (_currentUser == null), kita memanggil clearState() untuk membersihkan semua data sesi sebelumnya.
|
||||
|
||||
_setLoading(bool value): Fungsi internal untuk mengontrol tampilan CircularProgressIndicator di seluruh aplikasi.
|
||||
|
||||
setDeviceIdAndFetchData(...): Fungsi ini dipanggil setelah login berhasil. Ia menyimpan deviceId, kemudian memanggil fungsi lain untuk mengambil data awal dan mulai mendengarkan perubahan realtime.
|
||||
|
||||
fetchEvents() & fetchDeviceStatus(): Fungsi ini mengambil data dari tabel Supabase (sensor_events dan device_status) menggunakan select(). Data ini kemudian disimpan dalam variabel state.
|
||||
|
||||
_listenToRealtimeChanges() (PERBAIKAN ERROR):
|
||||
|
||||
Fungsi ini sekarang menggunakan sintaks baru dari supabase_flutter v2.
|
||||
|
||||
supabase.channel(...): Membuat channel komunikasi realtime.
|
||||
|
||||
.onPostgresChanges(...): Ini adalah fungsi spesifik untuk mendengarkan perubahan pada database PostgreSQL Anda.
|
||||
|
||||
event: PostgresChangeEvent.insert: Hanya mendengarkan event INSERT (ketika ada data baru masuk).
|
||||
|
||||
schema: 'public', table: '...': Menentukan tabel mana yang ingin dipantau.
|
||||
|
||||
filter: ...: Filter tambahan agar kita hanya mendapatkan notifikasi untuk deviceId yang sedang dipantau.
|
||||
|
||||
.listen((payload) { ... }): Callback yang akan dieksekusi ketika ada data baru. payload.new berisi data baris yang baru saja ditambahkan.
|
||||
|
||||
setSleepSchedule(...): Fungsi ini dipanggil saat pengguna menekan tombol "Set Timer". Ia melakukan update() pada tabel device_status, mengisi kolom schedule_duration_microseconds dan setter_user_id dengan nilai yang baru.
|
||||
|
||||
3. Halaman SplashScreen (Baru)
|
||||
StatefulWidget: Diperlukan karena kita perlu mengelola state selama proses navigasi.
|
||||
|
||||
initState(): Metode ini dipanggil sekali saat widget pertama kali dibuat. Di sinilah kita memulai timer.
|
||||
|
||||
Future.delayed(const Duration(seconds: 3), ...): Menjalankan kode di dalamnya setelah jeda 3 detik.
|
||||
|
||||
Navigator.pushReplacement(...): Setelah 3 detik, kita pindah ke AuthWrapper. pushReplacement digunakan agar pengguna tidak bisa kembali ke splash screen dengan menekan tombol "back".
|
||||
|
||||
4. AuthWrapper
|
||||
Seperti yang dijelaskan, ini adalah widget tanpa tampilan yang berfungsi sebagai router cerdas.
|
||||
|
||||
Consumer<AppState>: Widget ini secara otomatis "mendengarkan" AppState. Setiap kali notifyListeners() dipanggil di AppState, bagian builder dari Consumer ini akan dieksekusi ulang.
|
||||
|
||||
if (appState.currentUser != null && appState.deviceId != null): Logika inti. Jika ada pengguna yang login DAN deviceId sudah diatur, tampilkan DashboardPage. Jika tidak, tampilkan LoginPage.
|
||||
|
||||
5. LoginPage
|
||||
StatefulWidget & TextEditingController: Digunakan untuk mengelola input teks dari pengguna.
|
||||
|
||||
_formKey: Digunakan untuk validasi form (memastikan kolom tidak kosong).
|
||||
|
||||
_signIn():
|
||||
|
||||
Memeriksa validasi form.
|
||||
|
||||
Memanggil supabase.auth.signInWithPassword(...).
|
||||
|
||||
Jika berhasil, ia memanggil Provider.of<AppState>(context, listen: false).setDeviceIdAndFetchData(...) untuk memberi tahu AppState bahwa pengguna telah login dengan deviceId tertentu, sehingga AppState bisa mulai mengambil data.
|
||||
|
||||
Menangani AuthException jika login gagal (misalnya, password salah).
|
||||
|
||||
6. RegisterPage
|
||||
Mirip dengan LoginPage, tetapi memanggil supabase.auth.signUp(...).
|
||||
|
||||
data: {'full_name': ...}: Ini adalah bagian penting. Di sinilah kita menyimpan data tambahan (nama lengkap) ke dalam kolom raw_user_meta_data di tabel auth.users Supabase.
|
||||
|
||||
7. DashboardPage & Komponen-komponennya
|
||||
DashboardPage: Menggunakan CustomScrollView dan Sliver untuk layout yang efisien dan memungkinkan adanya refresh indicator.
|
||||
|
||||
ProfileBar: Mengambil data pengguna (fullName, email) dari AppState dan menampilkannya. Tombol logout memanggil appState.signOut().
|
||||
|
||||
SettingsBar:
|
||||
|
||||
Menampilkan status online/offline berdasarkan data dari _deviceStatus di AppState.
|
||||
|
||||
Tombol "Set Timer" hanya aktif jika isOnline == true.
|
||||
|
||||
_showSetTimerDialog(): Memunculkan TimePicker bawaan Flutter. Logikanya menghitung selisih antara waktu sekarang dan waktu yang dipilih pengguna, mengonversinya ke mikrodetik, lalu memanggil appState.setSleepSchedule() untuk mengirim data ke Supabase.
|
||||
|
||||
FootageGallery & FootageCard:
|
||||
|
||||
FootageGallery menampilkan SliverList dari FootageCard berdasarkan data _events di AppState.
|
||||
|
||||
FootageCard adalah kartu individu yang menampilkan gambar (Image.network), lokasi, tipe event, dan waktu kejadian. Desain kartu dibuat dengan BoxDecoration untuk memberikan efek bayangan (mirip 3D) dan sudut yang tumpul (borderRadius).
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
-- 1. TABEL UNTUK MENCATAT SEMUA EVENT DARI SENSOR
|
||||
CREATE TABLE public.sensor_events (
|
||||
id TEXT PRIMARY KEY NOT NULL, -- Format: device_id + epoch. Contoh: garasi_01_1672531200
|
||||
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
event_timestamp TIMESTAMPTZ NOT NULL, -- Waktu asli saat event terjadi (dari RTC)
|
||||
event_type TEXT NOT NULL, -- Contoh: 'GERAKAN' atau 'GETARAN'
|
||||
location_name TEXT,
|
||||
image_ref TEXT -- URL publik ke gambar di Supabase Storage
|
||||
);
|
||||
COMMENT ON TABLE public.sensor_events IS 'Mencatat setiap event yang terdeteksi oleh sensor.';
|
||||
|
||||
-- 2. TABEL UNTUK MEMANTAU STATUS PERANGKAT DAN JADWAL SLEEP
|
||||
CREATE TABLE public.device_status (
|
||||
device_id TEXT PRIMARY KEY NOT NULL, -- Contoh: 'garasi_01'
|
||||
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||
is_online BOOLEAN DEFAULT false,
|
||||
last_status_update TIMESTAMPTZ,
|
||||
schedule_duration_microseconds BIGINT DEFAULT 0, -- Durasi sleep dalam mikrodetik
|
||||
setter_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL -- Foreign Key ke tabel pengguna
|
||||
);
|
||||
COMMENT ON TABLE public.device_status IS 'Memantau status online/offline dan jadwal deep sleep perangkat.';
|
||||
|
||||
-- Tambahkan trigger untuk otomatis memperbarui kolom 'updated_at'
|
||||
create extension if not exists moddatetime schema extensions;
|
||||
|
||||
create trigger handle_updated_at before update on public.device_status
|
||||
for each row execute procedure extensions.moddatetime (updated_at);
|
||||
|
||||
create trigger handle_updated_at_events before update on public.sensor_events
|
||||
for each row execute procedure extensions.moddatetime (created_at);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:CCTV_App/login_page.dart';
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
// =================================================================
|
||||
// AUTH & ROUTING
|
||||
// =================================================================
|
||||
|
||||
class AuthWrapper extends StatelessWidget {
|
||||
const AuthWrapper({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<AuthState>(
|
||||
stream: supabase.auth.onAuthStateChange,
|
||||
builder: (context, snapshot) {
|
||||
// Tampilkan loading spinner selagi menunggu event auth pertama.
|
||||
if (!snapshot.hasData) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Sesuai alur aplikasi, selalu mulai dari LoginPage untuk meminta Device ID,
|
||||
// terlepas dari status sesi sebelumnya.
|
||||
return const LoginPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
class AppConstants {
|
||||
static const String footageBucket =
|
||||
'newata-footage-bucket'; // Ganti dengan nama bucket Anda
|
||||
}
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
// =================================================================
|
||||
// DASHBOARD PAGE & COMPONENTS
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/login_page.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// =================================================================
|
||||
// DASHBOARD PAGE & COMPONENTS
|
||||
// =================================================================
|
||||
|
||||
class DashboardPage extends StatelessWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
if (appState.isLoading && appState.events.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: Provider.of<AppState>(context, listen: false)
|
||||
.fetchInitialData,
|
||||
color: Colors.tealAccent,
|
||||
backgroundColor: const Color(0xFF2c2c2c),
|
||||
child: const CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: ProfileBar()),
|
||||
SliverToBoxAdapter(child: SettingsBar()),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 20)),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text("Footage Gallery",
|
||||
style: TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.bold)),
|
||||
)),
|
||||
SliverToBoxAdapter(child: SizedBox(height: 10)),
|
||||
FootageGallery(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return Visibility(
|
||||
visible: appState.isLoading,
|
||||
child: const LinearProgressIndicator(
|
||||
color: Colors.tealAccent,
|
||||
backgroundColor: Colors.transparent),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileBar extends StatelessWidget {
|
||||
const ProfileBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = Provider.of<AppState>(context, listen: false).currentUser;
|
||||
final fullName = user?.userMetadata?['full_name'] ?? 'No Name';
|
||||
final email = user?.email ?? 'No Email';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
child: Text(fullName.isNotEmpty ? fullName[0].toUpperCase() : 'U',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(fullName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
Text(email,
|
||||
style:
|
||||
const TextStyle(fontSize: 14, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Logout",
|
||||
icon: const Icon(Icons.logout, color: Colors.white70),
|
||||
onPressed: () async {
|
||||
final bool? confirmLogout = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFF2c2c2c),
|
||||
title: const Text('Konfirmasi Logout',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
content: const Text('Apakah Anda yakin ingin keluar?',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Batal',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Logout',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (confirmLogout == true) {
|
||||
// PERBAIKAN: Navigasi dulu, baru proses logout di latar belakang.
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const LoginPage(showLogoutSuccess: true)),
|
||||
(route) => false,
|
||||
);
|
||||
|
||||
await appState.signOut();
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsBar extends StatelessWidget {
|
||||
const SettingsBar({super.key});
|
||||
|
||||
void _showSetTimerDialog(BuildContext context, AppState appState) async {
|
||||
final now = DateTime.now();
|
||||
final TimeOfDay? pickedTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(now.add(const Duration(hours: 1))),
|
||||
);
|
||||
|
||||
if (pickedTime != null) {
|
||||
DateTime scheduledTime = DateTime(
|
||||
now.year, now.month, now.day, pickedTime.hour, pickedTime.minute);
|
||||
if (scheduledTime.isBefore(now)) {
|
||||
scheduledTime = scheduledTime.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
final duration = scheduledTime.difference(now);
|
||||
final durationMicroseconds = duration.inMicroseconds;
|
||||
|
||||
final error = await appState.setSleepSchedule(durationMicroseconds);
|
||||
|
||||
if (context.mounted) {
|
||||
if (error == null) {
|
||||
final formattedTime = DateFormat.jm('id_ID').format(scheduledTime);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Jadwal deep sleep diatur hingga $formattedTime'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Gagal mengatur jadwal: $error'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final statusData = appState.deviceStatus;
|
||||
|
||||
if (statusData == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Center(
|
||||
child: Text("Mencari status perangkat...",
|
||||
style: TextStyle(color: Colors.white24))),
|
||||
);
|
||||
}
|
||||
|
||||
final deviceId = statusData['device_id'] ?? 'N/A';
|
||||
final deviceStatus = statusData['status'] ?? 'unknown';
|
||||
final isOnline = deviceStatus == 'active';
|
||||
final lastUpdateString =
|
||||
statusData['last_update'] ?? DateTime.now().toIso8601String();
|
||||
final lastUpdate = DateTime.parse(lastUpdateString).toLocal();
|
||||
final formattedLastUpdate =
|
||||
DateFormat('dd MMM, HH:mm:ss', 'id_ID').format(lastUpdate);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Device: $deviceId',
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle,
|
||||
color: isOnline
|
||||
? Colors.greenAccent
|
||||
: Colors.redAccent,
|
||||
size: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text(isOnline ? 'Active' : 'Inactive',
|
||||
style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Last update: $formattedLastUpdate',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.white54),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isOnline
|
||||
? () => _showSetTimerDialog(context, appState)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.teal,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.withOpacity(0.3)),
|
||||
child: const Text('Set Timer'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FootageGallery extends StatelessWidget {
|
||||
const FootageGallery({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final events = appState.events;
|
||||
if (events.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(CupertinoIcons.photo_on_rectangle,
|
||||
size: 60, color: Colors.white24),
|
||||
SizedBox(height: 16),
|
||||
Text('Belum ada tangkapan',
|
||||
style: TextStyle(color: Colors.white24, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final event = events[index];
|
||||
return FootageCard(event: event);
|
||||
},
|
||||
childCount: events.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FootageCard extends StatelessWidget {
|
||||
final Map<String, dynamic> event;
|
||||
const FootageCard({super.key, required this.event});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = event['image_ref'] as String?;
|
||||
final location = event['location'] ?? 'Unknown Location';
|
||||
final eventType = event['event_type'] ?? 'Unknown Event';
|
||||
final timestampString =
|
||||
event['timestamp'] ?? DateTime.now().toIso8601String();
|
||||
final timestamp = DateTime.parse(timestampString);
|
||||
|
||||
final formattedTime = DateFormat('EEEE, dd MMMM yyyy, HH:mm:ss', 'id_ID')
|
||||
.format(timestamp.toLocal());
|
||||
|
||||
String displayEventType = 'Unknown';
|
||||
Color eventColor = Colors.grey;
|
||||
if (eventType == 'motion') {
|
||||
displayEventType = 'Motion Detected';
|
||||
eventColor = Colors.orange.shade800;
|
||||
} else if (eventType == 'vibration') {
|
||||
displayEventType = 'Vibration Detected';
|
||||
eventColor = Colors.purple.shade800;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (imageUrl != null && imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: 250,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
height: 250,
|
||||
color: Colors.black12,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.tealAccent,
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
height: 250,
|
||||
color: Colors.black12,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.exclamationmark_triangle,
|
||||
color: Colors.redAccent, size: 50),
|
||||
SizedBox(height: 8),
|
||||
Text("Gagal memuat gambar",
|
||||
style: TextStyle(color: Colors.white70))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100,
|
||||
color: Colors.black12,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.videocam,
|
||||
size: 30, color: Colors.white24),
|
||||
SizedBox(height: 8),
|
||||
Text("No image captured",
|
||||
style: TextStyle(color: Colors.white24))
|
||||
],
|
||||
)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: eventColor,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(
|
||||
displayEventType,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.location_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(location,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.time_solid,
|
||||
size: 16, color: Colors.white70),
|
||||
const SizedBox(width: 8),
|
||||
Text(formattedTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// import 'package:CCTV_App/dashboard_page.dart';
|
||||
import 'package:CCTV_App/dashboard_page.dart';
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:CCTV_App/register_page.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
final bool showLogoutSuccess;
|
||||
|
||||
const LoginPage({super.key, this.showLogoutSuccess = false});
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _deviceIdController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
// PERBAIKAN: Menambahkan initState untuk menampilkan snackbar jika diperlukan.
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.showLogoutSuccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Anda telah berhasil logout.'),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _signIn() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
|
||||
try {
|
||||
final authResponse = await supabase.auth.signInWithPassword(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (authResponse.user == null) {
|
||||
throw 'Login gagal, pengguna tidak ditemukan.';
|
||||
}
|
||||
|
||||
final deviceIdIsValid = await appState
|
||||
.setDeviceIdAndFetchData(_deviceIdController.text.trim());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (deviceIdIsValid) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const DashboardPage()),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Login berhasil, tetapi Device ID tidak terdaftar.'),
|
||||
backgroundColor: Colors.orange,
|
||||
));
|
||||
await appState.signOut();
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(e.message),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Terjadi kesalahan: ${e.toString()}'),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userEmail = supabase.auth.currentUser?.email;
|
||||
if (userEmail != null && _emailController.text.isEmpty) {
|
||||
_emailController.text = userEmail;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.shield_lefthalf_fill,
|
||||
size: 80, color: Colors.tealAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Security Monitor',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Email tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Password tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _deviceIdController,
|
||||
decoration: const InputDecoration(labelText: 'Device ID'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Device ID tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signIn,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.tealAccent,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.black))
|
||||
: const Text('Login',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const RegisterPage())),
|
||||
child: const Text('Belum punya akun? Daftar sekarang',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,63 +1,32 @@
|
|||
// main.dart
|
||||
import 'package:CCTV_App/auth_wrapper.dart';
|
||||
import 'package:CCTV_App/provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
// Providers
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/pantau_footage_provider.dart';
|
||||
import 'providers/schedule_provider.dart';
|
||||
|
||||
// Screens
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/register_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/video_player_screen.dart'; // Pastikan path benar
|
||||
import 'screens/image_viewer_screen.dart'; // Pastikan path benar
|
||||
// =================================================================
|
||||
// MAIN.DART & SETUP
|
||||
// =================================================================
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
print("main: WidgetsFlutterBinding initialized."); // Log
|
||||
await dotenv.load(fileName: ".env");
|
||||
|
||||
try {
|
||||
await dotenv.load(fileName: ".env");
|
||||
print("main: .env file loaded successfully."); // Log
|
||||
} catch (e) {
|
||||
print("main: Error loading .env file: $e"); // Log error
|
||||
}
|
||||
await Supabase.initialize(
|
||||
url: dotenv.env['SUPABASE_URL']!,
|
||||
anonKey: dotenv.env['SUPABASE_ANON_KEY']!,
|
||||
);
|
||||
|
||||
final supabaseUrl = dotenv.env['SUPABASE_URL'];
|
||||
final supabaseAnonKey = dotenv.env['SUPABASE_ANON_KEY'];
|
||||
await initializeDateFormatting('id_ID', null);
|
||||
|
||||
if (supabaseUrl == null || supabaseAnonKey == null) {
|
||||
print(
|
||||
"main: Supabase URL or Anon Key not found in .env file. App cannot initialize Supabase."); // Log error
|
||||
return;
|
||||
}
|
||||
print("main: Supabase URL and Anon Key found."); // Log
|
||||
|
||||
try {
|
||||
await Supabase.initialize(
|
||||
url: supabaseUrl,
|
||||
anonKey: supabaseAnonKey,
|
||||
);
|
||||
print("main: Supabase initialized successfully."); // Log
|
||||
} catch (e) {
|
||||
print("main: Error initializing Supabase: $e"); // Log error
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await initializeDateFormatting('id_ID', null);
|
||||
print("main: Date formatting initialized."); // Log
|
||||
} catch (e) {
|
||||
print("main: Error initializing date formatting: $e"); // Log error
|
||||
}
|
||||
|
||||
runApp(const MyApp());
|
||||
runApp(
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AppState(),
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final supabase = Supabase.instance.client;
|
||||
|
|
@ -67,137 +36,42 @@ class MyApp extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("MyApp: Building..."); // Log
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
ChangeNotifierProxyProvider<AuthProvider, FootageProvider>(
|
||||
create: (_) => FootageProvider(null),
|
||||
update: (_, auth, previousFootage) =>
|
||||
FootageProvider(auth.currentUser?.id),
|
||||
return MaterialApp(
|
||||
title: 'Security Monitor',
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: Colors.tealAccent,
|
||||
scaffoldBackgroundColor: const Color(0xFF1a1a1a),
|
||||
cardColor: const Color(0xFF2c2c2c),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: Colors.tealAccent,
|
||||
secondary: Colors.teal,
|
||||
surface: Color(0xFF2c2c2c),
|
||||
onSurface: Colors.white,
|
||||
),
|
||||
ChangeNotifierProxyProvider<AuthProvider, ScheduleProvider>(
|
||||
create: (_) => ScheduleProvider(null),
|
||||
update: (_, auth, previousSchedule) =>
|
||||
ScheduleProvider(auth.currentUser?.id),
|
||||
textTheme: const TextTheme(
|
||||
bodyLarge: TextStyle(color: Colors.white70),
|
||||
bodyMedium: TextStyle(color: Colors.white70),
|
||||
titleLarge:
|
||||
TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
titleMedium: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'CCTV IoT App',
|
||||
theme: ThemeData(
|
||||
// Tema tetap sama
|
||||
primarySwatch: Colors.teal,
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
fontFamily: 'Inter',
|
||||
scaffoldBackgroundColor: Colors.grey[100],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.teal[600],
|
||||
elevation: 2,
|
||||
iconTheme:
|
||||
const IconThemeData(color: Colors.white), // Warna ikon AppBar
|
||||
titleTextStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500) // Style title AppBar
|
||||
),
|
||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||
selectedItemColor: Colors.teal[700],
|
||||
unselectedItemColor: Colors.grey[600],
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.teal[500],
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
|
||||
)),
|
||||
cardTheme: CardTheme(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey[400]!),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Colors.teal),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 14.0, horizontal: 12.0),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.teal[600],
|
||||
),
|
||||
),
|
||||
dialogTheme: DialogTheme(
|
||||
// Style untuk dialog (misal loading)
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
elevation: 5,
|
||||
),
|
||||
timePickerTheme: TimePickerThemeData(
|
||||
// Style untuk TimePicker
|
||||
backgroundColor: Colors.white,
|
||||
hourMinuteShape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
side: BorderSide(color: Colors.grey[300]!, width: 1),
|
||||
),
|
||||
dayPeriodShape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
side: BorderSide(color: Colors.grey[300]!, width: 1),
|
||||
),
|
||||
dayPeriodColor: Colors.teal[50],
|
||||
dayPeriodTextColor: Colors.teal[800],
|
||||
hourMinuteColor: WidgetStateColor.resolveWith((states) =>
|
||||
states.contains(WidgetState.selected)
|
||||
? Colors.teal[100]!
|
||||
: Colors.grey[100]!),
|
||||
hourMinuteTextColor: WidgetStateColor.resolveWith((states) =>
|
||||
states.contains(WidgetState.selected)
|
||||
? Colors.teal[900]!
|
||||
: Colors.black54),
|
||||
dialHandColor: Colors.teal[300],
|
||||
dialBackgroundColor: Colors.teal[50],
|
||||
dialTextColor: WidgetStateColor.resolveWith((states) =>
|
||||
states.contains(WidgetState.selected)
|
||||
? Colors.white
|
||||
: Colors.teal[900]!),
|
||||
entryModeIconColor: Colors.teal[600],
|
||||
helpTextStyle: TextStyle(color: Colors.teal[800]),
|
||||
),
|
||||
),
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: SplashScreen(),
|
||||
routes: {
|
||||
'/login': (context) => const LoginScreen(),
|
||||
'/register': (context) => const RegisterScreen(),
|
||||
'/dashboard': (context) => const DashboardScreen(),
|
||||
'/video_player': (context) => VideoPlayerScreen(
|
||||
videoUrl:
|
||||
ModalRoute.of(context)?.settings.arguments as String? ?? '',
|
||||
),
|
||||
'/image_viewer': (context) => ImageViewerScreen(
|
||||
imageUrl:
|
||||
ModalRoute.of(context)?.settings.arguments as String? ?? '',
|
||||
),
|
||||
},
|
||||
fillColor: Colors.black.withOpacity(0.3),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none),
|
||||
labelStyle: const TextStyle(color: Colors.white54)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const AuthWrapper(), // Menggunakan AuthWrapper untuk logika awal
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SUPABASE_URL=https://ihuetazxejsioxcjejpj.supabase.co
|
||||
// SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlodWV0YXp4ZWpzaW94Y2planBqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExMDEyMTgsImV4cCI6MjA2NjY3NzIxOH0.IKFyrKioiScfAS_9UouPEcesHHoas0SDZH0mZBCA1ro
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
// =================================================================
|
||||
// STATE MANAGEMENT (PROVIDER)
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
String? _deviceId;
|
||||
List<Map<String, dynamic>> _events = [];
|
||||
Map<String, dynamic>? _deviceStatus;
|
||||
User? _currentUser;
|
||||
|
||||
RealtimeChannel? _eventsChannel;
|
||||
RealtimeChannel? _deviceStatusChannel;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get deviceId => _deviceId;
|
||||
List<Map<String, dynamic>> get events => _events;
|
||||
Map<String, dynamic>? get deviceStatus => _deviceStatus;
|
||||
User? get currentUser => _currentUser;
|
||||
|
||||
AppState() {
|
||||
_currentUser = supabase.auth.currentUser;
|
||||
supabase.auth.onAuthStateChange.listen((data) {
|
||||
final session = data.session;
|
||||
_currentUser = session?.user;
|
||||
if (_currentUser == null) {
|
||||
clearState();
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _unsubscribeFromChannels() {
|
||||
if (_eventsChannel != null) {
|
||||
supabase.removeChannel(_eventsChannel!);
|
||||
_eventsChannel = null;
|
||||
}
|
||||
if (_deviceStatusChannel != null) {
|
||||
supabase.removeChannel(_deviceStatusChannel!);
|
||||
_deviceStatusChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
void clearState() {
|
||||
_deviceId = null;
|
||||
_events.clear();
|
||||
_deviceStatus = null;
|
||||
_unsubscribeFromChannels();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> setDeviceIdAndFetchData(String deviceId) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('device_status')
|
||||
.select('device_id')
|
||||
.eq('device_id', deviceId)
|
||||
.maybeSingle();
|
||||
|
||||
if (response == null) {
|
||||
_setLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_deviceId = deviceId;
|
||||
await fetchInitialData();
|
||||
_listenToRealtimeChanges();
|
||||
notifyListeners();
|
||||
_setLoading(false);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error validating device ID: $e');
|
||||
_setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchInitialData() async {
|
||||
if (_deviceId == null) return;
|
||||
_setLoading(true);
|
||||
await Future.wait([
|
||||
fetchEvents(),
|
||||
fetchDeviceStatus(),
|
||||
]);
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
Future<void> fetchEvents() async {
|
||||
if (_deviceId == null) return;
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('events')
|
||||
.select()
|
||||
.eq('device_id', _deviceId!)
|
||||
.order('timestamp', ascending: false);
|
||||
_events = List<Map<String, dynamic>>.from(response);
|
||||
} catch (e) {
|
||||
print('Error fetching events: $e');
|
||||
_events = [];
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> fetchDeviceStatus() async {
|
||||
if (_deviceId == null) return;
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('device_status')
|
||||
.select()
|
||||
.eq('device_id', _deviceId!)
|
||||
.single();
|
||||
_deviceStatus = response;
|
||||
} catch (e) {
|
||||
print('Error fetching device status: $e');
|
||||
_deviceStatus = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _listenToRealtimeChanges() {
|
||||
if (_deviceId == null) return;
|
||||
_unsubscribeFromChannels();
|
||||
|
||||
_eventsChannel = supabase.channel('public:events:device_id=$_deviceId');
|
||||
_eventsChannel!
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.insert,
|
||||
schema: 'public',
|
||||
table: 'events',
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'device_id',
|
||||
value: _deviceId!,
|
||||
),
|
||||
callback: (payload) {
|
||||
final newEvent = payload.newRecord;
|
||||
if (!_events.any((e) => e['id'] == newEvent['id'])) {
|
||||
_events.insert(0, newEvent);
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
_deviceStatusChannel =
|
||||
supabase.channel('public:device_status:device_id=$_deviceId');
|
||||
_deviceStatusChannel!
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.update,
|
||||
schema: 'public',
|
||||
table: 'device_status',
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'device_id',
|
||||
value: _deviceId!,
|
||||
),
|
||||
callback: (payload) {
|
||||
_deviceStatus = payload.newRecord;
|
||||
notifyListeners();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
_setLoading(true);
|
||||
await supabase.auth.signOut();
|
||||
// clearState() akan dipanggil oleh listener onAuthStateChange
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
Future<String?> setSleepSchedule(int durationMicroseconds) async {
|
||||
if (_deviceId == null) {
|
||||
return "Device not identified.";
|
||||
}
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
await supabase.from('device_status').update({
|
||||
'schedule_duration_microseconds': durationMicroseconds,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
}).eq('device_id', _deviceId!);
|
||||
|
||||
_setLoading(false);
|
||||
return null;
|
||||
} on PostgrestException catch (e) {
|
||||
_setLoading(false);
|
||||
print('Error setting sleep schedule: ${e.message}');
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
_setLoading(false);
|
||||
print('Generic error setting sleep schedule: $e');
|
||||
return 'An unexpected error occurred.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
// providers/auth_provider.dart (Sudah Lengkap)
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
final SupabaseClient _supabase = Supabase.instance.client;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
User? _currentUser;
|
||||
User? get currentUser => _currentUser;
|
||||
|
||||
AuthProvider() {
|
||||
print("AuthProvider: Initializing...");
|
||||
_currentUser = _supabase.auth.currentUser;
|
||||
print("AuthProvider: Initial user state: ${_currentUser?.id ?? 'null'}");
|
||||
|
||||
_supabase.auth.onAuthStateChange.listen((data) {
|
||||
final AuthChangeEvent event = data.event;
|
||||
final Session? session = data.session;
|
||||
print(
|
||||
"AuthProvider: onAuthStateChange event: $event, session: ${session?.user.id ?? 'null'}");
|
||||
_currentUser = session?.user;
|
||||
_errorMessage = null;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
});
|
||||
print("AuthProvider: Listener attached.");
|
||||
}
|
||||
|
||||
void _setState({bool? loading, String? error}) {
|
||||
bool changed = false;
|
||||
final prevLoading = _isLoading;
|
||||
final prevError = _errorMessage;
|
||||
|
||||
if (loading != null && _isLoading != loading) {
|
||||
_isLoading = loading;
|
||||
changed = true;
|
||||
}
|
||||
if (_errorMessage != error) {
|
||||
_errorMessage = error;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
print(
|
||||
"AuthProvider: State changed - isLoading: $prevLoading -> $_isLoading, error: '$prevError' -> '$_errorMessage'");
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> signUp(String email, String password) async {
|
||||
print("AuthProvider: signUp called with email: $email");
|
||||
_setState(loading: true, error: null);
|
||||
try {
|
||||
final AuthResponse res =
|
||||
await _supabase.auth.signUp(email: email, password: password);
|
||||
print(
|
||||
"AuthProvider: signUp successful for ${res.user?.email}. User: ${res.user?.id}, Session: ${res.session?.accessToken ?? 'null'}");
|
||||
_setState(loading: false, error: null);
|
||||
return true;
|
||||
} on AuthException catch (e) {
|
||||
print('AuthProvider: signUp AuthException: ${e.message}');
|
||||
_setState(loading: false, error: 'Registrasi gagal: ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('AuthProvider: signUp Unknown Error: $e');
|
||||
_setState(loading: false, error: 'Terjadi kesalahan tidak diketahui.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signIn(String email, String password) async {
|
||||
print("AuthProvider: signIn called with email: $email");
|
||||
_setState(loading: true, error: null);
|
||||
try {
|
||||
final AuthResponse res = await _supabase.auth
|
||||
.signInWithPassword(email: email, password: password);
|
||||
print(
|
||||
"AuthProvider: signIn successful for ${res.user?.email}. User: ${res.user?.id}, Session: ${res.session?.accessToken ?? 'null'}");
|
||||
_setState(loading: false, error: null);
|
||||
} on AuthException catch (e) {
|
||||
print('AuthProvider: signIn AuthException: ${e.message}');
|
||||
_setState(loading: false, error: 'Login gagal: ${e.message}');
|
||||
} catch (e) {
|
||||
print('AuthProvider: signIn Unknown Error: $e');
|
||||
_setState(loading: false, error: 'Terjadi kesalahan tidak diketahui.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
print("AuthProvider: signOut called for user: ${_currentUser?.id}");
|
||||
_setState(loading: true, error: null);
|
||||
try {
|
||||
await _supabase.auth.signOut();
|
||||
print("AuthProvider: signOut successful.");
|
||||
_setState(loading: false, error: null);
|
||||
} catch (e) {
|
||||
print('AuthProvider: signOut Error: $e');
|
||||
_setState(loading: false, error: 'Logout gagal: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
// providers/footage_provider.dart (Sudah Lengkap)
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../constant.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class FootageProvider extends ChangeNotifier {
|
||||
final String? userId;
|
||||
FootageProvider(this.userId) {
|
||||
print("FootageProvider: Initialized with userId: $userId");
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _mediaItems = [];
|
||||
List<Map<String, dynamic>> get mediaItems => _mediaItems;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
Future<void> fetchMediaItems() async {
|
||||
print("FootageProvider: fetchMediaItems called for userId: $userId");
|
||||
if (userId == null) {
|
||||
print("FootageProvider: fetchMediaItems aborted, userId is null.");
|
||||
_errorMessage = "User tidak terautentikasi.";
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('videos')
|
||||
.select(
|
||||
'id, user_id, storage_object_path, uploaded_at, media_type, metadata')
|
||||
.eq('user_id', userId!)
|
||||
.order('uploaded_at', ascending: false);
|
||||
|
||||
_mediaItems = List<Map<String, dynamic>>.from(response as List);
|
||||
print(
|
||||
"FootageProvider: fetchMediaItems successful, found ${_mediaItems.length} items.");
|
||||
} catch (e) {
|
||||
print('FootageProvider: Error fetching media items: $e');
|
||||
_errorMessage = 'Gagal memuat daftar media: ${e.toString()}';
|
||||
_mediaItems = [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getMediaUrl(String storagePath) async {
|
||||
print("FootageProvider: getMediaUrl called for path: $storagePath");
|
||||
try {
|
||||
final signedUrlResponse = await supabase.storage
|
||||
.from(AppConstants.footageBucket)
|
||||
.createSignedUrl(storagePath, 60 * 5); // Expire dalam 5 menit
|
||||
print("FootageProvider: Signed URL generated successfully.");
|
||||
return signedUrlResponse;
|
||||
} catch (e) {
|
||||
print('FootageProvider: Error getting media URL: $e');
|
||||
_errorMessage = 'Gagal mendapatkan URL media.';
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
// providers/schedule_provider.dart (Sudah Lengkap)
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class ScheduleProvider extends ChangeNotifier {
|
||||
final String? userId;
|
||||
ScheduleProvider(this.userId) {
|
||||
print("ScheduleProvider: Initialized with userId: $userId");
|
||||
}
|
||||
|
||||
TimeOfDay? _startTime;
|
||||
TimeOfDay? get startTime => _startTime;
|
||||
|
||||
TimeOfDay? _endTime;
|
||||
TimeOfDay? get endTime => _endTime;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
final _dbTimeFormat = DateFormat('HH:mm:ss');
|
||||
|
||||
Future<void> fetchSchedule() async {
|
||||
print("ScheduleProvider: fetchSchedule called for userId: $userId");
|
||||
if (userId == null) {
|
||||
print("ScheduleProvider: fetchSchedule aborted, userId is null.");
|
||||
_errorMessage = "User tidak terautentikasi.";
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await supabase
|
||||
.from('device_schedule')
|
||||
.select('inactive_start_time, inactive_end_time')
|
||||
.eq('user_id', userId!)
|
||||
.maybeSingle();
|
||||
|
||||
if (response != null) {
|
||||
final data = response;
|
||||
final startTimeString = data['inactive_start_time'] as String?;
|
||||
final endTimeString = data['inactive_end_time'] as String?;
|
||||
|
||||
_startTime = (startTimeString != null)
|
||||
? TimeOfDay.fromDateTime(_dbTimeFormat.parse(startTimeString))
|
||||
: null;
|
||||
_endTime = (endTimeString != null)
|
||||
? TimeOfDay.fromDateTime(_dbTimeFormat.parse(endTimeString))
|
||||
: null;
|
||||
print(
|
||||
"ScheduleProvider: fetchSchedule successful. Start: $_startTime, End: $_endTime");
|
||||
} else {
|
||||
_startTime = null;
|
||||
_endTime = null;
|
||||
print("ScheduleProvider: fetchSchedule - No schedule found.");
|
||||
}
|
||||
} catch (e) {
|
||||
print('ScheduleProvider: Error fetching schedule: $e');
|
||||
_errorMessage = 'Gagal memuat jadwal: ${e.toString()}';
|
||||
_startTime = null;
|
||||
_endTime = null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveSchedule(
|
||||
TimeOfDay newStartTime, TimeOfDay newEndTime) async {
|
||||
print(
|
||||
"ScheduleProvider: saveSchedule called. Start: $newStartTime, End: $newEndTime");
|
||||
if (userId == null) {
|
||||
print("ScheduleProvider: saveSchedule aborted, userId is null.");
|
||||
_errorMessage = "User tidak terautentikasi.";
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final startDateTime = DateTime(
|
||||
now.year, now.month, now.day, newStartTime.hour, newStartTime.minute);
|
||||
final endDateTime = DateTime(
|
||||
now.year, now.month, now.day, newEndTime.hour, newEndTime.minute);
|
||||
final startTimeString = _dbTimeFormat.format(startDateTime);
|
||||
final endTimeString = _dbTimeFormat.format(endDateTime);
|
||||
|
||||
await supabase.from('device_schedule').upsert({
|
||||
'user_id': userId!,
|
||||
'inactive_start_time': startTimeString,
|
||||
'inactive_end_time': endTimeString,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}, onConflict: 'user_id');
|
||||
|
||||
_startTime = newStartTime;
|
||||
_endTime = newEndTime;
|
||||
_isLoading = false;
|
||||
print("ScheduleProvider: saveSchedule successful.");
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('ScheduleProvider: Error saving schedule: $e');
|
||||
_errorMessage = 'Gagal menyimpan jadwal: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends State<RegisterPage> {
|
||||
final _fullNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _signUp() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await supabase.auth.signUp(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text.trim(),
|
||||
data: {'full_name': _fullNameController.text.trim()},
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Registrasi berhasil! Silakan periksa email untuk verifikasi & login.'),
|
||||
backgroundColor: Colors.green,
|
||||
));
|
||||
Navigator.pop(context);
|
||||
} on AuthException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(e.message),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text("Error: ${e.toString()}"),
|
||||
backgroundColor: Colors.redAccent,
|
||||
));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.person_add_solid,
|
||||
size: 80, color: Colors.tealAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Buat Akun Baru',
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _fullNameController,
|
||||
decoration: const InputDecoration(labelText: 'Nama Lengkap'),
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Nama tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) =>
|
||||
value!.isEmpty ? 'Email tidak boleh kosong' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(labelText: 'Password'),
|
||||
obscureText: true,
|
||||
validator: (value) =>
|
||||
value!.length < 6 ? 'Password minimal 6 karakter' : null,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signUp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.tealAccent,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.black))
|
||||
: const Text('Register',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,722 +0,0 @@
|
|||
// screens/control_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/schedule_provider.dart'; // Pastikan path benar
|
||||
|
||||
class ControlScreen extends StatefulWidget {
|
||||
const ControlScreen({super.key});
|
||||
|
||||
@override
|
||||
_ControlScreenState createState() => _ControlScreenState();
|
||||
}
|
||||
|
||||
class _ControlScreenState extends State<ControlScreen> {
|
||||
// Hapus TextEditingController dan FormKey
|
||||
// final _startTimeController = TextEditingController();
|
||||
// final _endTimeController = TextEditingController();
|
||||
// final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Gunakan state lokal untuk menyimpan waktu yang DIPILIH user (sebelum disimpan)
|
||||
TimeOfDay? _selectedStartTime;
|
||||
TimeOfDay? _selectedEndTime;
|
||||
|
||||
// State lokal untuk menyimpan waktu dari provider (untuk tampilan jadwal saat ini)
|
||||
TimeOfDay? _fetchedStartTime;
|
||||
TimeOfDay? _fetchedEndTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("ControlScreen: initState called.");
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
print("ControlScreen: Requesting fetchSchedule.");
|
||||
final provider = context.read<ScheduleProvider>();
|
||||
provider.fetchSchedule().then((_) {
|
||||
if (mounted) {
|
||||
print(
|
||||
"ControlScreen: fetchSchedule completed. Updating local state.");
|
||||
// Set state lokal dari provider saat pertama kali load
|
||||
setState(() {
|
||||
_fetchedStartTime = provider.startTime;
|
||||
_fetchedEndTime = provider.endTime;
|
||||
// Juga set waktu terpilih awal agar tombol menampilkan nilai yang ada
|
||||
_selectedStartTime = provider.startTime;
|
||||
_selectedEndTime = provider.endTime;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Hapus dispose untuk controller
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _startTimeController.dispose();
|
||||
// _endTimeController.dispose();
|
||||
// super.dispose();
|
||||
// }
|
||||
|
||||
// Fungsi untuk menampilkan Time Picker
|
||||
Future<void> _selectTime(BuildContext context, bool isStartTime) async {
|
||||
print(
|
||||
"ControlScreen: Opening time picker for ${isStartTime ? 'start' : 'end'} time.");
|
||||
// Tentukan waktu awal untuk picker
|
||||
final TimeOfDay initialTime = isStartTime
|
||||
? (_selectedStartTime ?? _fetchedStartTime ?? TimeOfDay.now())
|
||||
: (_selectedEndTime ??
|
||||
_fetchedEndTime ??
|
||||
TimeOfDay(
|
||||
hour: (TimeOfDay.now().hour + 1) % 24,
|
||||
minute: TimeOfDay.now().minute)); // Default end time +1 jam
|
||||
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: initialTime,
|
||||
helpText: isStartTime ? 'PILIH WAKTU MULAI' : 'PILIH WAKTU SELESAI',
|
||||
// Gunakan tema dari MaterialApp
|
||||
// builder: (context, child) {
|
||||
// return Theme(
|
||||
// data: Theme.of(context), // Menggunakan tema utama
|
||||
// child: child!,
|
||||
// );
|
||||
// },
|
||||
);
|
||||
print("ControlScreen: Time picker closed. Picked: $picked");
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
if (isStartTime) {
|
||||
_selectedStartTime = picked;
|
||||
} else {
|
||||
_selectedEndTime = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Format TimeOfDay ke string HH:mm (24 jam)
|
||||
String _formatTimeOfDay(TimeOfDay? time) {
|
||||
if (time == null) return 'Belum diatur';
|
||||
final hour = time.hour.toString().padLeft(2, '0');
|
||||
final minute = time.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
// Fungsi untuk menyimpan jadwal
|
||||
Future<void> _saveNewSchedule() async {
|
||||
print("ControlScreen: Save button pressed.");
|
||||
// Validasi sederhana: pastikan kedua waktu sudah dipilih
|
||||
if (_selectedStartTime == null || _selectedEndTime == null) {
|
||||
print("ControlScreen: Save aborted, start or end time not selected.");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Harap pilih waktu mulai dan selesai.'),
|
||||
backgroundColor: Colors.orangeAccent),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validasi tambahan (opsional): cek apakah waktu mulai sebelum waktu selesai
|
||||
final startTimeMinutes =
|
||||
_selectedStartTime!.hour * 60 + _selectedStartTime!.minute;
|
||||
final endTimeMinutes =
|
||||
_selectedEndTime!.hour * 60 + _selectedEndTime!.minute;
|
||||
if (startTimeMinutes >= endTimeMinutes) {
|
||||
print("ControlScreen: Save aborted, start time is not before end time.");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Waktu mulai harus sebelum waktu selesai.'),
|
||||
backgroundColor: Colors.orangeAccent),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = context.read<ScheduleProvider>();
|
||||
final success =
|
||||
await provider.saveSchedule(_selectedStartTime!, _selectedEndTime!);
|
||||
print("ControlScreen: saveSchedule result: $success");
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? 'Jadwal berhasil disimpan!'
|
||||
: provider.errorMessage ?? 'Gagal menyimpan jadwal.'),
|
||||
backgroundColor: success ? Colors.green[600] : Colors.redAccent,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
// Update state fetched time setelah berhasil simpan
|
||||
setState(() {
|
||||
_fetchedStartTime = _selectedStartTime;
|
||||
_fetchedEndTime = _selectedEndTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("ControlScreen: Building UI.");
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Consumer<ScheduleProvider>(
|
||||
builder: (context, provider, child) {
|
||||
// Sinkronisasi state lokal jika provider berubah (misal setelah fetch awal)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted &&
|
||||
(_fetchedStartTime != provider.startTime ||
|
||||
_fetchedEndTime != provider.endTime)) {
|
||||
print(
|
||||
"ControlScreen Consumer: Provider state changed (fetch), updating local state.");
|
||||
setState(() {
|
||||
_fetchedStartTime = provider.startTime;
|
||||
_fetchedEndTime = provider.endTime;
|
||||
// Set juga selected time jika belum pernah dipilih user
|
||||
_selectedStartTime ??= provider.startTime;
|
||||
_selectedEndTime ??= provider.endTime;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
print(
|
||||
"ControlScreen Consumer: Building content. isLoading: ${provider.isLoading}, error: ${provider.errorMessage}");
|
||||
|
||||
// Logika tampilan jadwal saat ini (sama seperti sebelumnya)
|
||||
String currentScheduleDisplay;
|
||||
bool isScheduleSet =
|
||||
_fetchedStartTime != null && _fetchedEndTime != null;
|
||||
bool hasSchedulePassedToday = false;
|
||||
if (isScheduleSet) {
|
||||
final now = TimeOfDay.now();
|
||||
final nowInMinutes = now.hour * 60 + now.minute;
|
||||
final endTimeInMinutes =
|
||||
_fetchedEndTime!.hour * 60 + _fetchedEndTime!.minute;
|
||||
if (nowInMinutes > endTimeInMinutes) {
|
||||
hasSchedulePassedToday = true;
|
||||
}
|
||||
}
|
||||
if (!isScheduleSet || (isScheduleSet && hasSchedulePassedToday)) {
|
||||
currentScheduleDisplay =
|
||||
"Belum diatur atau jadwal hari ini telah lewat.";
|
||||
} else {
|
||||
currentScheduleDisplay =
|
||||
'${_formatTimeOfDay(_fetchedStartTime)} - ${_formatTimeOfDay(_fetchedEndTime)}';
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
print("ControlScreen: Refresh requested.");
|
||||
await provider.fetchSchedule();
|
||||
// Update state lokal setelah refresh
|
||||
setState(() {
|
||||
_fetchedStartTime = provider.startTime;
|
||||
_fetchedEndTime = provider.endTime;
|
||||
_selectedStartTime =
|
||||
provider.startTime; // Reset pilihan user ke data terbaru
|
||||
_selectedEndTime = provider.endTime;
|
||||
});
|
||||
},
|
||||
color: Colors.teal[600]!,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// Hapus Form widget
|
||||
// child: Form(
|
||||
// key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Jadwal Nonaktif CCTV',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.teal[800]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Atur rentang waktu kapan perangkat CCTV tidak akan merekam atau mengirim data.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[700]),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Tampilkan jadwal saat ini
|
||||
Card(
|
||||
/* ... (UI Card sama, menampilkan currentScheduleDisplay) ... */
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(/* ... (Header Card sama) ... */),
|
||||
const SizedBox(height: 10),
|
||||
if (provider.isLoading && !isScheduleSet)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3, color: Colors.teal)),
|
||||
)
|
||||
else if (provider.errorMessage != null &&
|
||||
!isScheduleSet)
|
||||
Text(provider.errorMessage!,
|
||||
style:
|
||||
const TextStyle(color: Colors.redAccent))
|
||||
else
|
||||
Text(
|
||||
currentScheduleDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: (isScheduleSet &&
|
||||
!hasSchedulePassedToday)
|
||||
? Colors.black87
|
||||
: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Tombol untuk memilih waktu (kembali ke OutlinedButton)
|
||||
Text(
|
||||
'Setel Jadwal Baru:',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600, color: Colors.teal[700]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.timer_outlined, size: 18),
|
||||
label: Text(_selectedStartTime != null
|
||||
? _formatTimeOfDay(_selectedStartTime)
|
||||
: 'Pilih Mulai'),
|
||||
onPressed: () => _selectTime(
|
||||
context, true), // Panggil _selectTime
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.teal,
|
||||
side: BorderSide(color: Colors.teal[200]!),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text("-",
|
||||
style: TextStyle(
|
||||
fontSize: 24, color: Colors.grey[600])),
|
||||
),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon:
|
||||
const Icon(Icons.timer_off_outlined, size: 18),
|
||||
label: Text(_selectedEndTime != null
|
||||
? _formatTimeOfDay(_selectedEndTime)
|
||||
: 'Pilih Selesai'),
|
||||
onPressed: () => _selectTime(
|
||||
context, false), // Panggil _selectTime
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.teal,
|
||||
side: BorderSide(color: Colors.teal[200]!),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Tombol Simpan
|
||||
Center(
|
||||
child: provider.isLoading
|
||||
? CircularProgressIndicator(color: Colors.teal[600])
|
||||
: ElevatedButton.icon(
|
||||
icon:
|
||||
const Icon(Icons.save_alt_outlined, size: 20),
|
||||
label: const Text('Simpan Jadwal'),
|
||||
onPressed: (_selectedStartTime == null ||
|
||||
_selectedEndTime == null)
|
||||
? null // Disable jika waktu belum dipilih
|
||||
: _saveNewSchedule, // Panggil fungsi simpan
|
||||
),
|
||||
),
|
||||
// Tampilkan error saving jika ada
|
||||
if (provider.errorMessage != null &&
|
||||
!provider.isLoading &&
|
||||
(_selectedStartTime != null ||
|
||||
_selectedEndTime !=
|
||||
null)) // Tampilkan jika ada error DAN user sudah mencoba memilih
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15.0),
|
||||
child: Center(
|
||||
child: Text(provider.errorMessage!,
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
// ), // Tutup Form (dihapus)
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// class ControlScreen extends StatefulWidget {
|
||||
// const ControlScreen({super.key});
|
||||
|
||||
// @override
|
||||
// _ControlScreenState createState() => _ControlScreenState();
|
||||
// }
|
||||
|
||||
// class _ControlScreenState extends State<ControlScreen> {
|
||||
// TimeOfDay? _selectedStartTime;
|
||||
// TimeOfDay? _selectedEndTime;
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// print("ControlScreen: initState called.");
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// print("ControlScreen: Requesting fetchSchedule.");
|
||||
// final provider = context.read<ScheduleProvider>();
|
||||
// provider.fetchSchedule().then((_) {
|
||||
// if (mounted) {
|
||||
// print(
|
||||
// "ControlScreen: fetchSchedule completed. Updating local state.");
|
||||
// setState(() {
|
||||
// _selectedStartTime = provider.startTime;
|
||||
// _selectedEndTime = provider.endTime;
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// Future<TimeOfDay?> _selectTime(
|
||||
// BuildContext context, TimeOfDay? initialTime) async {
|
||||
// print("ControlScreen: Opening time picker.");
|
||||
// final TimeOfDay? picked = await showTimePicker(
|
||||
// context: context,
|
||||
// initialTime: initialTime ?? TimeOfDay.now(),
|
||||
// helpText: 'PILIH WAKTU', // Ubah teks helper
|
||||
// builder: (context, child) {
|
||||
// // Gunakan tema dari MaterialApp
|
||||
// return child!;
|
||||
// // Atau override tema spesifik di sini jika perlu
|
||||
// // return Theme(
|
||||
// // data: Theme.of(context).copyWith(
|
||||
// // timePickerTheme: TimePickerThemeData(...) // Override spesifik
|
||||
// // ),
|
||||
// // child: child!,
|
||||
// // );
|
||||
// },
|
||||
// );
|
||||
// print("ControlScreen: Time picker closed. Picked: $picked");
|
||||
// return picked;
|
||||
// }
|
||||
|
||||
// String _formatTimeOfDay(TimeOfDay? time) {
|
||||
// if (time == null) return 'Belum diatur';
|
||||
// final now = DateTime.now();
|
||||
// final dt = DateTime(now.year, now.month, now.day, time.hour, time.minute);
|
||||
// return DateFormat('HH:mm', 'id_ID')
|
||||
// .format(dt); // Gunakan format 24 jam agar lebih jelas
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// print("ControlScreen: Building UI.");
|
||||
// return Scaffold(
|
||||
// // AppBar tidak perlu karena sudah ada di Dashboard
|
||||
// body: Padding(
|
||||
// padding: const EdgeInsets.all(16.0),
|
||||
// child: Consumer<ScheduleProvider>(
|
||||
// builder: (context, provider, child) {
|
||||
// // Sinkronisasi state lokal
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// if (mounted &&
|
||||
// (_selectedStartTime != provider.startTime ||
|
||||
// _selectedEndTime != provider.endTime)) {
|
||||
// print(
|
||||
// "ControlScreen Consumer: Provider state changed, updating local state.");
|
||||
// setState(() {
|
||||
// _selectedStartTime = provider.startTime;
|
||||
// _selectedEndTime = provider.endTime;
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// print(
|
||||
// "ControlScreen Consumer: Building content. isLoading: ${provider.isLoading}, error: ${provider.errorMessage}");
|
||||
|
||||
// return RefreshIndicator(
|
||||
// // Tambahkan RefreshIndicator
|
||||
// onRefresh: () async {
|
||||
// print("ControlScreen: Refresh requested.");
|
||||
// await provider.fetchSchedule();
|
||||
// },
|
||||
// color: Colors.teal,
|
||||
// child: SingleChildScrollView(
|
||||
// physics:
|
||||
// const AlwaysScrollableScrollPhysics(), // Agar bisa refresh walau konten pendek
|
||||
// child: Column(
|
||||
// crossAxisAlignment:
|
||||
// CrossAxisAlignment.stretch, // Buat elemen memenuhi lebar
|
||||
// children: [
|
||||
// Text(
|
||||
// 'Jadwal Nonaktif CCTV',
|
||||
// style: Theme.of(context)
|
||||
// .textTheme
|
||||
// .headlineSmall
|
||||
// ?.copyWith(
|
||||
// fontWeight: FontWeight.bold,
|
||||
// color: Colors.teal[800]),
|
||||
// ),
|
||||
// const SizedBox(height: 8),
|
||||
// Text(
|
||||
// 'Atur rentang waktu kapan perangkat CCTV tidak akan merekam atau mengirim data.',
|
||||
// style: Theme.of(context)
|
||||
// .textTheme
|
||||
// .bodyMedium
|
||||
// ?.copyWith(color: Colors.grey[700]),
|
||||
// ),
|
||||
// const SizedBox(height: 25),
|
||||
|
||||
// // Tampilkan jadwal saat ini
|
||||
// Card(
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(16.0),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// children: [
|
||||
// Row(
|
||||
// // Tambahkan ikon dan tombol refresh
|
||||
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
// children: [
|
||||
// Text(
|
||||
// 'Jadwal Saat Ini:',
|
||||
// style: Theme.of(context)
|
||||
// .textTheme
|
||||
// .titleMedium
|
||||
// ?.copyWith(
|
||||
// fontWeight: FontWeight.w600,
|
||||
// color: Colors.teal[700]),
|
||||
// ),
|
||||
// // Tombol refresh kecil
|
||||
// IconButton(
|
||||
// icon: Icon(Icons.refresh,
|
||||
// color: Colors.grey[500]),
|
||||
// tooltip: 'Muat Ulang Jadwal',
|
||||
// iconSize: 20,
|
||||
// onPressed: provider.isLoading
|
||||
// ? null
|
||||
// : () async {
|
||||
// print(
|
||||
// "ControlScreen: Inline refresh pressed.");
|
||||
// await provider.fetchSchedule();
|
||||
// },
|
||||
// )
|
||||
// ],
|
||||
// ),
|
||||
// const SizedBox(height: 10),
|
||||
// if (provider.isLoading &&
|
||||
// _selectedStartTime == null)
|
||||
// const Padding(
|
||||
// padding: EdgeInsets.symmetric(vertical: 10.0),
|
||||
// child: Center(
|
||||
// child: CircularProgressIndicator(
|
||||
// strokeWidth: 3, color: Colors.teal)),
|
||||
// )
|
||||
// else if (provider.errorMessage != null &&
|
||||
// _selectedStartTime == null)
|
||||
// Text(provider.errorMessage!,
|
||||
// style:
|
||||
// const TextStyle(color: Colors.redAccent))
|
||||
// else
|
||||
// Row(
|
||||
// mainAxisAlignment:
|
||||
// MainAxisAlignment.spaceAround, // Beri jarak
|
||||
// children: [
|
||||
// _buildTimeDisplay(
|
||||
// 'Mulai Nonaktif', _selectedStartTime),
|
||||
// Icon(Icons.arrow_forward,
|
||||
// color: Colors
|
||||
// .grey[400]), // Panah penanda rentang
|
||||
// _buildTimeDisplay(
|
||||
// 'Selesai Nonaktif', _selectedEndTime),
|
||||
// ],
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(height: 25),
|
||||
|
||||
// // Tombol untuk memilih waktu
|
||||
// Text(
|
||||
// 'Setel Jadwal Baru:',
|
||||
// style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
// fontWeight: FontWeight.w600, color: Colors.teal[700]),
|
||||
// ),
|
||||
// const SizedBox(height: 10),
|
||||
// Row(
|
||||
// children: [
|
||||
// Expanded(
|
||||
// child: OutlinedButton.icon(
|
||||
// icon: const Icon(Icons.timer_outlined,
|
||||
// size: 18), // Kecilkan ikon
|
||||
// label: Text(_selectedStartTime != null
|
||||
// ? _formatTimeOfDay(_selectedStartTime)
|
||||
// : 'Pilih Mulai'),
|
||||
// onPressed: () async {
|
||||
// final time = await _selectTime(
|
||||
// context, _selectedStartTime);
|
||||
// if (time != null) {
|
||||
// setState(() {
|
||||
// _selectedStartTime = time;
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// style: OutlinedButton.styleFrom(
|
||||
// foregroundColor: Colors.teal,
|
||||
// side: BorderSide(color: Colors.teal[200]!),
|
||||
// padding: const EdgeInsets.symmetric(
|
||||
// vertical: 12) // Sesuaikan padding
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(width: 10),
|
||||
// Expanded(
|
||||
// child: OutlinedButton.icon(
|
||||
// icon: const Icon(Icons.timer_off_outlined,
|
||||
// size: 18), // Kecilkan ikon
|
||||
// label: Text(_selectedEndTime != null
|
||||
// ? _formatTimeOfDay(_selectedEndTime)
|
||||
// : 'Pilih Selesai'),
|
||||
// onPressed: () async {
|
||||
// final time =
|
||||
// await _selectTime(context, _selectedEndTime);
|
||||
// if (time != null) {
|
||||
// setState(() {
|
||||
// _selectedEndTime = time;
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
// style: OutlinedButton.styleFrom(
|
||||
// foregroundColor: Colors.teal,
|
||||
// side: BorderSide(color: Colors.teal[200]!),
|
||||
// padding: const EdgeInsets.symmetric(
|
||||
// vertical: 12) // Sesuaikan padding
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// const SizedBox(
|
||||
// height: 30), // Beri jarak lebih sebelum tombol simpan
|
||||
|
||||
// // Tombol Simpan
|
||||
// Center(
|
||||
// child: provider.isLoading
|
||||
// ? const CircularProgressIndicator(color: Colors.teal)
|
||||
// : ElevatedButton.icon(
|
||||
// icon:
|
||||
// const Icon(Icons.save_alt_outlined, size: 20),
|
||||
// label: const Text('Simpan Jadwal',
|
||||
// style: TextStyle(fontSize: 16)),
|
||||
// style: ElevatedButton.styleFrom(
|
||||
// padding: const EdgeInsets.symmetric(
|
||||
// vertical: 14,
|
||||
// horizontal:
|
||||
// 30)), // Buat tombol lebih besar
|
||||
// onPressed: (_selectedStartTime == null ||
|
||||
// _selectedEndTime == null)
|
||||
// ? null // Disable jika waktu belum dipilih
|
||||
// : () async {
|
||||
// print(
|
||||
// "ControlScreen: Save button pressed.");
|
||||
// final success = await context
|
||||
// .read<ScheduleProvider>()
|
||||
// .saveSchedule(_selectedStartTime!,
|
||||
// _selectedEndTime!);
|
||||
// print(
|
||||
// "ControlScreen: saveSchedule result: $success");
|
||||
|
||||
// if (mounted) {
|
||||
// ScaffoldMessenger.of(context)
|
||||
// .showSnackBar(
|
||||
// SnackBar(
|
||||
// content: Text(success
|
||||
// ? 'Jadwal berhasil disimpan!'
|
||||
// : context
|
||||
// .read<
|
||||
// ScheduleProvider>()
|
||||
// .errorMessage ??
|
||||
// 'Gagal menyimpan jadwal.'),
|
||||
// backgroundColor: success
|
||||
// ? Colors.green[600]
|
||||
// : Colors.redAccent,
|
||||
// behavior: SnackBarBehavior
|
||||
// .floating, // SnackBar mengambang
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// // Tampilkan error saving jika ada
|
||||
// if (provider.errorMessage != null &&
|
||||
// !provider.isLoading &&
|
||||
// _selectedStartTime !=
|
||||
// null) // Tampilkan hanya jika user mencoba menyimpan
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.only(top: 15.0),
|
||||
// child: Center(
|
||||
// child: Text(provider.errorMessage!,
|
||||
// style: const TextStyle(color: Colors.redAccent),
|
||||
// textAlign: TextAlign.center),
|
||||
// ),
|
||||
// ),
|
||||
// const SizedBox(height: 20), // Padding bawah
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Helper widget untuk menampilkan waktu
|
||||
// Widget _buildTimeDisplay(String label, TimeOfDay? time) {
|
||||
// return Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.center,
|
||||
// children: [
|
||||
// Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
||||
// const SizedBox(height: 2),
|
||||
// Text(
|
||||
// _formatTimeOfDay(time),
|
||||
// style: const TextStyle(
|
||||
// fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87),
|
||||
// ),
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
// screens/dashboard_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart'; // Pastikan path benar
|
||||
import 'footage_screen.dart'; // Pastikan path benar
|
||||
import 'control_screen.dart'; // Pastikan path benar
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
_DashboardScreenState createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
static final List<Widget> _widgetOptions = <Widget>[
|
||||
const FootageScreen(),
|
||||
const ControlScreen(),
|
||||
];
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
print("DashboardScreen: Tab tapped, index: $index");
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("DashboardScreen: Building UI.");
|
||||
// Gunakan watch agar UI rebuild jika user berubah (meski tidak mungkin di screen ini)
|
||||
final userIdentifier =
|
||||
context.watch<AuthProvider>().currentUser?.email ?? 'Pengguna';
|
||||
print("DashboardScreen: Current user identifier: $userIdentifier");
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Halo, $userIdentifier!'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Keluar',
|
||||
onPressed: () async {
|
||||
print("DashboardScreen: Logout button pressed.");
|
||||
await context.read<AuthProvider>().signOut();
|
||||
if (mounted && context.read<AuthProvider>().currentUser == null) {
|
||||
print(
|
||||
"DashboardScreen: User is null after sign out, navigating to /login");
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context, '/login', (route) => false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
// Pertahankan state tiap halaman saat ganti tab
|
||||
index: _selectedIndex,
|
||||
children: _widgetOptions,
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed, // Tipe agar label selalu terlihat
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
activeIcon: Icon(Icons.video_library),
|
||||
label: 'Footage',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_remote_outlined),
|
||||
activeIcon: Icon(Icons.settings_remote),
|
||||
label: 'Kontrol Alat',
|
||||
),
|
||||
],
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: _onItemTapped,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
// screens/footage_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/pantau_footage_provider.dart'; // Pastikan path benar
|
||||
|
||||
// class FootageScreen extends StatefulWidget {
|
||||
// const FootageScreen({super.key});
|
||||
|
||||
// @override
|
||||
// _FootageScreenState createState() => _FootageScreenState();
|
||||
// }
|
||||
|
||||
// class _FootageScreenState extends State<FootageScreen> {
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// print("FootageScreen: initState called.");
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// print("FootageScreen: Requesting fetchMediaItems.");
|
||||
// context.read<FootageProvider>().fetchMediaItems();
|
||||
// });
|
||||
// }
|
||||
|
||||
// Future<void> _refreshMediaItems() async {
|
||||
// print("FootageScreen: Refresh requested.");
|
||||
// await context.read<FootageProvider>().fetchMediaItems();
|
||||
// }
|
||||
|
||||
// void _viewMedia(BuildContext context, Map<String, dynamic> mediaItem) async {
|
||||
// final storagePath = mediaItem['storage_object_path'] as String?;
|
||||
// final mediaType = mediaItem['media_type'] as String?;
|
||||
|
||||
// if (storagePath == null) {
|
||||
// print("FootageScreen: View media failed, storage path is null.");
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// const SnackBar(
|
||||
// content: Text('Path media tidak valid.'),
|
||||
// backgroundColor: Colors.orangeAccent),
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// print(
|
||||
// "FootageScreen: Viewing media type '$mediaType' at path '$storagePath'");
|
||||
|
||||
// showDialog(
|
||||
// context: context,
|
||||
// barrierDismissible: false,
|
||||
// builder: (BuildContext context) => const Dialog(
|
||||
// child: Padding(
|
||||
// padding: EdgeInsets.all(20.0),
|
||||
// child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
// CircularProgressIndicator(color: Colors.teal),
|
||||
// SizedBox(width: 20),
|
||||
// Text("Memuat media...")
|
||||
// ])),
|
||||
// ),
|
||||
// );
|
||||
|
||||
// final url = await context.read<FootageProvider>().getMediaUrl(storagePath);
|
||||
// if (mounted) Navigator.pop(context); // Tutup dialog loading
|
||||
|
||||
// if (url != null && mounted) {
|
||||
// print("FootageScreen: URL obtained: $url");
|
||||
// if (mediaType == 'video') {
|
||||
// print("FootageScreen: Navigating to /video_player");
|
||||
// Navigator.pushNamed(context, '/video_player', arguments: url);
|
||||
// } else if (mediaType == 'image') {
|
||||
// print("FootageScreen: Navigating to /image_viewer");
|
||||
// Navigator.pushNamed(context, '/image_viewer', arguments: url);
|
||||
// } else {
|
||||
// print("FootageScreen: Unknown media type '$mediaType', cannot open.");
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(
|
||||
// content: Text('Tipe media tidak dikenali: $mediaType'),
|
||||
// backgroundColor: Colors.orangeAccent),
|
||||
// );
|
||||
// }
|
||||
// } else if (mounted) {
|
||||
// print("FootageScreen: Failed to get URL.");
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(
|
||||
// content: Text(context.read<FootageProvider>().errorMessage ??
|
||||
// 'Gagal memuat URL media.'),
|
||||
// backgroundColor: Colors.redAccent),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// print("FootageScreen: Building UI.");
|
||||
// final dateTimeFormat =
|
||||
// DateFormat('EEEE, dd MMMM yyyy HH:mm', 'id_ID'); // Tambah tahun
|
||||
|
||||
// return Scaffold(
|
||||
// // AppBar tidak perlu karena sudah ada di Dashboard
|
||||
// body: Consumer<FootageProvider>(
|
||||
// builder: (context, provider, child) {
|
||||
// print(
|
||||
// "FootageScreen Consumer: Building list. isLoading: ${provider.isLoading}, itemCount: ${provider.mediaItems.length}, error: ${provider.errorMessage}");
|
||||
|
||||
// if (provider.isLoading && provider.mediaItems.isEmpty) {
|
||||
// return const Center(
|
||||
// child: CircularProgressIndicator(color: Colors.teal));
|
||||
// }
|
||||
|
||||
// if (provider.errorMessage != null) {
|
||||
// return Center(
|
||||
// child: Padding(
|
||||
// padding: const EdgeInsets.all(16.0),
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// const Icon(Icons.error_outline,
|
||||
// color: Colors.redAccent, size: 50),
|
||||
// const SizedBox(height: 10),
|
||||
// Text('Oops! Terjadi Kesalahan',
|
||||
// style: Theme.of(context).textTheme.titleLarge,
|
||||
// textAlign: TextAlign.center),
|
||||
// const SizedBox(height: 5),
|
||||
// Text(provider.errorMessage!,
|
||||
// textAlign: TextAlign.center,
|
||||
// style: TextStyle(color: Colors.grey[700])),
|
||||
// const SizedBox(height: 20),
|
||||
// ElevatedButton.icon(
|
||||
// icon: const Icon(Icons.refresh),
|
||||
// label: const Text('Coba Lagi'),
|
||||
// onPressed: _refreshMediaItems)
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (provider.mediaItems.isEmpty && !provider.isLoading) {
|
||||
// return Center(
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// Icon(Icons.videocam_off_outlined,
|
||||
// size: 60, color: Colors.grey[400]),
|
||||
// const SizedBox(height: 10),
|
||||
// Text('Belum ada rekaman media',
|
||||
// style: Theme.of(context)
|
||||
// .textTheme
|
||||
// .titleMedium
|
||||
// ?.copyWith(color: Colors.grey[600])),
|
||||
// const SizedBox(height: 20),
|
||||
// ElevatedButton.icon(
|
||||
// icon: const Icon(Icons.refresh),
|
||||
// label: const Text('Refresh'),
|
||||
// onPressed: _refreshMediaItems,
|
||||
// style: ElevatedButton.styleFrom(
|
||||
// backgroundColor: Colors.grey[300],
|
||||
// foregroundColor: Colors.grey[800]))
|
||||
// ],
|
||||
// ));
|
||||
// }
|
||||
|
||||
// // Tampilkan list media
|
||||
// return RefreshIndicator(
|
||||
// onRefresh: _refreshMediaItems,
|
||||
// color: Colors.teal,
|
||||
// child: ListView.builder(
|
||||
// padding: const EdgeInsets.all(8.0),
|
||||
// itemCount: provider.mediaItems.length,
|
||||
// itemBuilder: (context, index) {
|
||||
// final mediaItem = provider.mediaItems[index];
|
||||
// final storagePath = mediaItem['storage_object_path'] as String?;
|
||||
// final uploadedAtString = mediaItem['uploaded_at'] as String?;
|
||||
// final mediaType = mediaItem['media_type'] as String?;
|
||||
// DateTime? uploadedAt = (uploadedAtString != null)
|
||||
// ? DateTime.tryParse(uploadedAtString)?.toLocal()
|
||||
// : null;
|
||||
// final fileName =
|
||||
// storagePath?.split('/').last ?? 'Media Tidak Dikenal';
|
||||
|
||||
// IconData leadingIconData = Icons.perm_media_outlined;
|
||||
// Color iconColor = Colors.teal[700]!;
|
||||
// if (mediaType == 'video') {
|
||||
// leadingIconData = Icons.play_circle_outline;
|
||||
// } else if (mediaType == 'image') {
|
||||
// leadingIconData = Icons.image_outlined;
|
||||
// iconColor = Colors.indigo[700]!;
|
||||
// }
|
||||
|
||||
// return Card(
|
||||
// clipBehavior: Clip
|
||||
// .antiAlias, // Agar ripple effect tidak keluar batas card
|
||||
// child: ListTile(
|
||||
// leading: CircleAvatar(
|
||||
// backgroundColor: iconColor.withOpacity(0.1),
|
||||
// child: Icon(leadingIconData, color: iconColor),
|
||||
// ),
|
||||
// title: Text(
|
||||
// fileName,
|
||||
// style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
// maxLines: 1,
|
||||
// overflow: TextOverflow.ellipsis,
|
||||
// ),
|
||||
// subtitle: Text(
|
||||
// uploadedAt != null
|
||||
// ? dateTimeFormat.format(uploadedAt)
|
||||
// : 'Tanggal tidak diketahui',
|
||||
// style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
// ),
|
||||
// trailing:
|
||||
// Icon(Icons.chevron_right, color: Colors.grey[400]),
|
||||
// onTap: () => _viewMedia(context, mediaItem),
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
class FootageScreen extends StatefulWidget {
|
||||
const FootageScreen({super.key});
|
||||
|
||||
@override
|
||||
_FootageScreenState createState() => _FootageScreenState();
|
||||
}
|
||||
|
||||
class _FootageScreenState extends State<FootageScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("FootageScreen: initState called.");
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
print("FootageScreen: Requesting fetchMediaItems.");
|
||||
context.read<FootageProvider>().fetchMediaItems();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshMediaItems() async {
|
||||
print("FootageScreen: Refresh requested.");
|
||||
await context.read<FootageProvider>().fetchMediaItems();
|
||||
}
|
||||
|
||||
void _viewMedia(BuildContext context, Map<String, dynamic> mediaItem) async {
|
||||
final storagePath = mediaItem['storage_object_path'] as String?;
|
||||
final mediaType = mediaItem['media_type'] as String?;
|
||||
|
||||
if (storagePath == null) {
|
||||
print("FootageScreen: View media failed, storage path is null.");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Path media tidak valid.'),
|
||||
backgroundColor: Colors.orangeAccent),
|
||||
);
|
||||
return;
|
||||
}
|
||||
print(
|
||||
"FootageScreen: Viewing media type '$mediaType' at path '$storagePath'");
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) => Dialog(
|
||||
backgroundColor: Colors.white, // Background dialog
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 24.0, horizontal: 20.0),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircularProgressIndicator(color: Colors.teal[600]),
|
||||
const SizedBox(width: 24),
|
||||
const Text("Memuat media...", style: TextStyle(fontSize: 16)),
|
||||
]),
|
||||
)),
|
||||
);
|
||||
|
||||
final url = await context.read<FootageProvider>().getMediaUrl(storagePath);
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
if (url != null && mounted) {
|
||||
/* ... (logika navigasi sama) ... */
|
||||
} else if (mounted) {/* ... (logika error sama) ... */}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("FootageScreen: Building UI.");
|
||||
// final dateTimeFormat =
|
||||
// DateFormat('EEEE, dd MMMM yyyy HH:mm', 'id_ID'); // Tambah tahun
|
||||
|
||||
return Scaffold(
|
||||
body: Consumer<FootageProvider>(
|
||||
builder: (context, provider, child) {
|
||||
print(
|
||||
"FootageScreen Consumer: Building list. isLoading: ${provider.isLoading}, itemCount: ${provider.mediaItems.length}, error: ${provider.errorMessage}");
|
||||
|
||||
if (provider.isLoading && provider.mediaItems.isEmpty) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: Colors.teal[600]));
|
||||
}
|
||||
|
||||
if (provider.errorMessage != null) {
|
||||
return const Center(
|
||||
/* ... (Widget error sama, mungkin perbesar font) ... */);
|
||||
}
|
||||
|
||||
if (provider.mediaItems.isEmpty && !provider.isLoading) {
|
||||
return const Center(
|
||||
/* ... (Widget 'belum ada rekaman' sama, mungkin perbesar font/ikon) ... */);
|
||||
}
|
||||
|
||||
// Gunakan GridView untuk tampilan lebih modern dan mengisi ruang
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshMediaItems,
|
||||
color: Colors.teal[600]!,
|
||||
child: GridView.builder(
|
||||
padding:
|
||||
const EdgeInsets.all(12.0), // Padding lebih besar untuk grid
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: MediaQuery.of(context).size.width > 600
|
||||
? 3
|
||||
: 2, // 2 kolom di HP, 3 di tablet
|
||||
crossAxisSpacing: 12.0,
|
||||
mainAxisSpacing: 12.0,
|
||||
childAspectRatio:
|
||||
1.0, // Buat item kotak, atau sesuaikan (misal 3/2 untuk landscape)
|
||||
),
|
||||
itemCount: provider.mediaItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final mediaItem = provider.mediaItems[index];
|
||||
final storagePath = mediaItem['storage_object_path'] as String?;
|
||||
final uploadedAtString = mediaItem['uploaded_at'] as String?;
|
||||
final mediaType = mediaItem['media_type'] as String?;
|
||||
DateTime? uploadedAt = (uploadedAtString != null)
|
||||
? DateTime.tryParse(uploadedAtString)?.toLocal()
|
||||
: null;
|
||||
final fileName =
|
||||
storagePath?.split('/').last ?? 'Media Tidak Dikenal';
|
||||
|
||||
IconData itemIconData = Icons.perm_media_outlined;
|
||||
Color itemIconColor = Colors.teal[700]!;
|
||||
if (mediaType == 'video') {
|
||||
itemIconData = Icons.play_circle_filled_outlined;
|
||||
} else if (mediaType == 'image') {
|
||||
itemIconData = Icons.image;
|
||||
itemIconColor = Colors.indigo[700]!;
|
||||
}
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 3, // Shadow lebih tegas
|
||||
child: InkWell(
|
||||
// Tambahkan InkWell untuk ripple effect
|
||||
onTap: () => _viewMedia(context, mediaItem),
|
||||
child: GridTile(
|
||||
footer: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0, vertical: 6.0),
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (uploadedAt != null)
|
||||
Text(
|
||||
DateFormat('dd MMM yy, HH:mm', 'id_ID')
|
||||
.format(uploadedAt),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[300], fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
// Latar belakang untuk ikon jika tidak ada thumbnail
|
||||
color: itemIconColor.withOpacity(0.05),
|
||||
child: Center(
|
||||
child: Icon(itemIconData,
|
||||
size: 50, color: itemIconColor.withOpacity(0.7)),
|
||||
),
|
||||
// TODO: Jika Anda punya URL thumbnail, ganti dengan Image.network di sini
|
||||
// child: mediaItem['thumbnail_url'] != null
|
||||
// ? Image.network(mediaItem['thumbnail_url'], fit: BoxFit.cover)
|
||||
// : Center(child: Icon(itemIconData, size: 50, color: itemIconColor.withOpacity(0.7))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// screens/image_viewer_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImageViewerScreen extends StatelessWidget {
|
||||
final String imageUrl;
|
||||
|
||||
const ImageViewerScreen({super.key, required this.imageUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("ImageViewerScreen: Building UI with URL: $imageUrl");
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: const Text('Penampil Gambar'),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
panEnabled: true,
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
loadingBuilder: (BuildContext context, Widget child,
|
||||
ImageChunkEvent? loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (BuildContext context, Object exception,
|
||||
StackTrace? stackTrace) {
|
||||
print("ImageViewerScreen: Error loading image: $exception");
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image_outlined,
|
||||
color: Colors.grey[600], size: 60),
|
||||
const SizedBox(height: 10),
|
||||
Text('Gagal memuat gambar',
|
||||
style: TextStyle(color: Colors.grey[400])),
|
||||
],
|
||||
));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
// screens/login_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart'; // Pastikan path benar
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
_LoginScreenState createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isPasswordVisible = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _performLogin() async {
|
||||
print("LoginScreen: _performLogin called.");
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
if (_formKey.currentState!.validate()) {
|
||||
print("LoginScreen: Form is valid.");
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
await authProvider.signIn(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
print("LoginScreen: Form is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("LoginScreen: Building UI.");
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(30.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cut_outlined, size: 80, color: Colors.teal[400]),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Selamat Datang Kembali!',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: Colors.teal[800]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Masuk ke akun Anda',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Input Email
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'Masukkan email Anda', // Tambah hint
|
||||
prefixIcon:
|
||||
Icon(Icons.email_outlined, color: Colors.grey[600]),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autovalidateMode: AutovalidateMode
|
||||
.onUserInteraction, // Validasi saat interaksi
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Email tidak boleh kosong';
|
||||
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
|
||||
return 'Masukkan format email yang valid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Input Password
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Masukkan password Anda', // Tambah hint
|
||||
prefixIcon:
|
||||
Icon(Icons.lock_outline, color: Colors.grey[600]),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordVisible
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _isPasswordVisible = !_isPasswordVisible)),
|
||||
),
|
||||
obscureText: !_isPasswordVisible,
|
||||
autovalidateMode: AutovalidateMode
|
||||
.onUserInteraction, // Validasi saat interaksi
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Password tidak boleh kosong';
|
||||
if (value.length < 6) return 'Password minimal 6 karakter';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Tombol Login & Loading Indicator
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, authProvider, child) {
|
||||
if (authProvider.currentUser != null &&
|
||||
!authProvider.isLoading) {
|
||||
print(
|
||||
"LoginScreen Consumer: User detected (${authProvider.currentUser!.id}), navigating to dashboard.");
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted &&
|
||||
ModalRoute.of(context)?.isCurrent == true) {
|
||||
Navigator.pushReplacementNamed(context, '/dashboard');
|
||||
}
|
||||
});
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CircularProgressIndicator(color: Colors.teal),
|
||||
);
|
||||
}
|
||||
|
||||
return authProvider.isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child:
|
||||
CircularProgressIndicator(color: Colors.teal),
|
||||
)
|
||||
: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical:
|
||||
14)), // Buat tombol lebih tinggi
|
||||
child: const Text('Login',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
onPressed: _performLogin,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Link ke Halaman Register
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("Belum punya akun?",
|
||||
style: TextStyle(color: Colors.grey[700])),
|
||||
TextButton(
|
||||
child: const Text('Daftar di sini'),
|
||||
onPressed: () {
|
||||
print("LoginScreen: Navigating to /register");
|
||||
Navigator.pushNamed(context, '/register');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Tampilkan Pesan Error
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, child) {
|
||||
if (auth.errorMessage != null && !auth.isLoading) {
|
||||
print(
|
||||
"LoginScreen Consumer: Displaying error message: ${auth.errorMessage}");
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
auth.errorMessage!,
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
// screens/register_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/auth_provider.dart'; // Pastikan path benar
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
_RegisterScreenState createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _isPasswordVisible = false;
|
||||
bool _isConfirmPasswordVisible = false;
|
||||
String? _successMessage;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _performRegister() async {
|
||||
print("RegisterScreen: _performRegister called.");
|
||||
setState(() {
|
||||
_successMessage = null;
|
||||
});
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
if (_formKey.currentState!.validate()) {
|
||||
print("RegisterScreen: Form is valid.");
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final bool success = await authProvider.signUp(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted && success) {
|
||||
print("RegisterScreen: SignUp reported success from provider.");
|
||||
setState(() {
|
||||
_successMessage =
|
||||
"Registrasi berhasil! Jika konfirmasi email aktif, silakan cek email Anda sebelum login.";
|
||||
});
|
||||
_formKey.currentState?.reset();
|
||||
_emailController.clear();
|
||||
_passwordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
} else if (mounted) {
|
||||
print("RegisterScreen: SignUp reported failure from provider.");
|
||||
}
|
||||
} else {
|
||||
print("RegisterScreen: Form is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("RegisterScreen: Building UI.");
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Daftar Akun Baru'),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.teal[800],
|
||||
iconTheme: IconThemeData(color: Colors.teal[800]), // Warna ikon back
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(30.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_add_alt_1_outlined,
|
||||
size: 60, color: Colors.teal[400]),
|
||||
const SizedBox(height: 15),
|
||||
Text(
|
||||
'Buat Akun Anda',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: Colors.teal[800]),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Input Email
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'Masukkan email Anda',
|
||||
prefixIcon: Icon(Icons.email_outlined)),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Email tidak boleh kosong';
|
||||
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
|
||||
return 'Masukkan format email yang valid';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Input Password
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Minimal 6 karakter',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_isPasswordVisible
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined),
|
||||
onPressed: () => setState(
|
||||
() => _isPasswordVisible = !_isPasswordVisible))),
|
||||
obscureText: !_isPasswordVisible,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Password tidak boleh kosong';
|
||||
if (value.length < 6) return 'Password minimal 6 karakter';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Input Konfirmasi Password
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Konfirmasi Password',
|
||||
hintText: 'Ulangi password',
|
||||
prefixIcon: const Icon(Icons.lock_clock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_isConfirmPasswordVisible
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined),
|
||||
onPressed: () => setState(() =>
|
||||
_isConfirmPasswordVisible =
|
||||
!_isConfirmPasswordVisible))),
|
||||
obscureText: !_isConfirmPasswordVisible,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Konfirmasi password tidak boleh kosong';
|
||||
if (value != _passwordController.text)
|
||||
return 'Password tidak cocok';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Tampilkan Pesan Sukses jika ada
|
||||
if (_successMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 15.0),
|
||||
child: Text(
|
||||
_successMessage!,
|
||||
style: TextStyle(
|
||||
color: Colors.green[700],
|
||||
fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Tombol Daftar & Loading Indicator
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, authProvider, child) {
|
||||
if (_successMessage != null) {
|
||||
// Jika sukses, tampilkan tombol kembali ke login
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.teal,
|
||||
side: BorderSide(color: Colors.teal[200]!),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('Kembali ke Login'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return authProvider.isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child:
|
||||
CircularProgressIndicator(color: Colors.teal),
|
||||
)
|
||||
: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 14)),
|
||||
onPressed: _performRegister,
|
||||
child: const Text('Daftar',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Tampilkan Pesan Error (Hanya jika tidak ada pesan sukses)
|
||||
if (_successMessage == null)
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, child) {
|
||||
if (auth.errorMessage != null && !auth.isLoading) {
|
||||
print(
|
||||
"RegisterScreen Consumer: Displaying error message: ${auth.errorMessage}");
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
auth.errorMessage!,
|
||||
style: const TextStyle(color: Colors.redAccent),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
// screens/splash_screen.dart (Sudah Lengkap)
|
||||
import 'package:CCTV_App/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
@override
|
||||
_SplashScreenState createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("SplashScreen: initState called.");
|
||||
_redirect();
|
||||
}
|
||||
|
||||
Future<void> _redirect() async {
|
||||
await Future.delayed(Duration.zero);
|
||||
if (!mounted) return;
|
||||
print("SplashScreen: Starting redirection check...");
|
||||
|
||||
try {
|
||||
final session = supabase.auth.currentSession;
|
||||
print("SplashScreen: Current session: ${session?.user.id ?? 'null'}");
|
||||
|
||||
if (session != null) {
|
||||
print("SplashScreen: Session found, redirecting to /dashboard");
|
||||
Navigator.pushReplacementNamed(context, '/dashboard');
|
||||
} else {
|
||||
print("SplashScreen: No session found, redirecting to /login");
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
} catch (e) {
|
||||
print("SplashScreen: Error during redirection check: $e");
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print("SplashScreen: Building UI.");
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.teal),
|
||||
SizedBox(height: 20),
|
||||
Text("Memuat...", style: TextStyle(color: Colors.teal)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
// screens/video_player_screen.dart (Sudah Lengkap)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
|
||||
class VideoPlayerScreen extends StatefulWidget {
|
||||
final String videoUrl;
|
||||
const VideoPlayerScreen({super.key, required this.videoUrl});
|
||||
|
||||
@override
|
||||
_VideoPlayerScreenState createState() => _VideoPlayerScreenState();
|
||||
}
|
||||
|
||||
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
|
||||
VideoPlayerController? _videoPlayerController;
|
||||
ChewieController? _chewieController;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print("VideoPlayerScreen: initState called with URL: ${widget.videoUrl}");
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
if (widget.videoUrl.isEmpty || !Uri.tryParse(widget.videoUrl)!.isAbsolute) {
|
||||
print("VideoPlayerScreen: Invalid URL provided.");
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = "URL Video tidak valid.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
print("VideoPlayerScreen: Initializing video controller...");
|
||||
_videoPlayerController =
|
||||
VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl));
|
||||
await _videoPlayerController!.initialize();
|
||||
print("VideoPlayerScreen: Video controller initialized.");
|
||||
|
||||
print("VideoPlayerScreen: Initializing Chewie controller...");
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: _videoPlayerController!,
|
||||
autoPlay: true,
|
||||
looping: false,
|
||||
materialProgressColors: ChewieProgressColors(
|
||||
playedColor: Colors.teal,
|
||||
handleColor: Colors.teal[300]!,
|
||||
bufferedColor: Colors.teal[100]!,
|
||||
backgroundColor:
|
||||
Colors.blueGrey[600]!, // Sesuaikan warna background progress
|
||||
),
|
||||
placeholder: Container(
|
||||
color: Colors.black,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white70)), // Buat lebih redup
|
||||
),
|
||||
autoInitialize: true,
|
||||
// Opsi tambahan:
|
||||
// showControlsOnInitialize: false,
|
||||
// allowedScreenSleep: false, // Jaga layar tetap nyala
|
||||
errorBuilder: (context, errorMessage) {
|
||||
print(
|
||||
"VideoPlayerScreen: Chewie errorBuilder: $errorMessage"); // Log error Chewie
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
color: Colors.white70, size: 40),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Gagal memutar video',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
Text(
|
||||
errorMessage,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
print("VideoPlayerScreen: Chewie controller initialized.");
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
print("VideoPlayerScreen: Error initializing video player: $e");
|
||||
print(stackTrace);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = "Gagal memuat video.";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
print("VideoPlayerScreen: dispose called.");
|
||||
_videoPlayerController?.dispose();
|
||||
_chewieController?.dispose(); // Chewie controller juga perlu di-dispose
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print(
|
||||
"VideoPlayerScreen: Building UI. isLoading: $_isLoading, error: $_errorMessage");
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: const Text('Pemutar Video'),
|
||||
backgroundColor:
|
||||
Colors.black.withOpacity(0.7), // AppBar sedikit transparan
|
||||
elevation: 0,
|
||||
),
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: _errorMessage != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
// Pastikan chewieController tidak null SEBELUM menampilkannya
|
||||
: _chewieController != null
|
||||
? Chewie(controller: _chewieController!)
|
||||
: const Column(
|
||||
// Fallback jika chewie controller gagal dibuat
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline,
|
||||
color: Colors.white70, size: 40),
|
||||
SizedBox(height: 10),
|
||||
Text("Gagal menyiapkan pemutar video.",
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// =================================================================
|
||||
// SPLASH SCREEN PAGE
|
||||
// =================================================================
|
||||
|
||||
import 'package:CCTV_App/auth_wrapper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const AuthWrapper()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.lock_shield_fill,
|
||||
size: 120, color: Colors.tealAccent),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Smart Anti Theft',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontSize: 28),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
const CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white54),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,19 +6,13 @@ import FlutterMacOS
|
|||
import Foundation
|
||||
|
||||
import app_links
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
import video_player_avfoundation
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
// import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
// import { Resend } from 'https://esm.sh/resend@3.2.0';
|
||||
// console.log("Edge Function 'notify-all-users' is initializing.");
|
||||
// Deno.serve(async (req)=>{
|
||||
// try {
|
||||
// // 1. Ambil data event dari request yang dikirim oleh Trigger Postgres
|
||||
// const { record } = await req.json();
|
||||
// console.log(`New event received for device: ${record.device_id}`);
|
||||
// // 2. Ambil Kunci API Resend dari environment variables
|
||||
// const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
|
||||
// if (!RESEND_API_KEY) {
|
||||
// throw new Error("RESEND_API_KEY is not set in Supabase secrets.");
|
||||
// }
|
||||
// const resend = new Resend(RESEND_API_KEY);
|
||||
// // 3. Buat Supabase client di dalam Edge Function untuk mengambil daftar email
|
||||
// const supabaseAdmin = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '');
|
||||
// // 4. Ambil semua email dari tabel 'profiles'
|
||||
// const { data: profiles, error: profileError } = await supabaseAdmin.from('profiles').select('email');
|
||||
// if (profileError) {
|
||||
// throw new Error(`Failed to fetch profiles: ${profileError.message}`);
|
||||
// }
|
||||
// if (!profiles || profiles.length === 0) {
|
||||
// console.log("No user profiles found to notify.");
|
||||
// return new Response(JSON.stringify({
|
||||
// message: "No users to notify."
|
||||
// }), {
|
||||
// headers: {
|
||||
// "Content-Type": "application/json"
|
||||
// },
|
||||
// status: 200
|
||||
// });
|
||||
// }
|
||||
// const emails = profiles.map((p)=>p.email).filter(Boolean); // Ambil email dan filter null/kosong
|
||||
// console.log(`Found ${emails.length} emails to notify:`, emails);
|
||||
// // 5. Kirim email ke semua pengguna menggunakan Resend
|
||||
// const { data, error } = await resend.emails.send({
|
||||
// from: 'Sistem Smart Anti Theft <onboarding@resend.dev>',
|
||||
// to: emails,
|
||||
// subject: `⚠️ Peringatan Ancaman: Terdeteksi ${record.event_type} di ${record.location_name}!`,
|
||||
// html: `
|
||||
// <h1>Peringatan Ancaman</h1>
|
||||
// <p>Halo,</p>
|
||||
// <p>Sistem kami telah mendeteksi adanya <strong>${record.event_type}</strong> pada perangkat <strong>${record.device_id}</strong> yang berlokasi di <strong>${record.location_name}</strong>.</p>
|
||||
// <p>Sebuah gambar telah berhasil ditangkap sebagai referensi.</p>
|
||||
// <img src="${record.image_ref}" alt="Captured Image" style="max-width: 400px; border-radius: 8px;" />
|
||||
// <p>Silakan periksa aplikasi Anda untuk detail lebih lanjut.</p>
|
||||
// <br/>
|
||||
// <p>Terima kasih,</p>
|
||||
// <p>Tim Keamanan Cerdas Anda</p>
|
||||
// `
|
||||
// });
|
||||
// if (error) {
|
||||
// console.error("Error sending email:", error);
|
||||
// throw new Error(JSON.stringify(error));
|
||||
// }
|
||||
// console.log("Successfully sent emails:", data);
|
||||
// return new Response(JSON.stringify({
|
||||
// data
|
||||
// }), {
|
||||
// headers: {
|
||||
// "Content-Type": "application/json"
|
||||
// },
|
||||
// status: 200
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Critical error in Edge Function:", error.message);
|
||||
// return new Response(JSON.stringify({
|
||||
// error: error.message
|
||||
// }), {
|
||||
// headers: {
|
||||
// "Content-Type": "application/json"
|
||||
// },
|
||||
// status: 500
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
|
@ -33,14 +33,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -65,14 +57,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
chewie:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: chewie
|
||||
sha256: "645fbca3f22309381edb5af59a4c8aa544a3d3872d7b7b7c986c2b18b3bdd265"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -97,14 +81,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -113,14 +89,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -200,14 +168,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -320,22 +280,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -392,14 +336,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -669,46 +605,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "7d78f0cfaddc8c19d4cb2d3bebe1bfef11f2103b0a03e5398b303a1bf65eeb14"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.5"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.16"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.1"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.5"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -717,22 +613,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.5"
|
||||
wakelock_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: a474e314c3e8fb5adef1f9ae2d247e57467ad557fa7483a2b895bc1b421c5678
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: e10444072e50dbc4999d7316fd303f7ea53d31c824aa5eb05d7ccbdd98985207
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -757,14 +637,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.10.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -773,14 +645,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
yet_another_json_isolate:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ dependencies:
|
|||
sdk: flutter
|
||||
supabase_flutter: ^2.0.0 # Cek versi terbaru
|
||||
provider: ^6.0.0 # Cek versi terbaru
|
||||
video_player: ^2.0.0 # Cek versi terbaru
|
||||
chewie: ^1.5.0 # Cek versi terbaru (sesuaikan dengan video_player)
|
||||
# video_player: ^2.0.0 # Cek versi terbaru
|
||||
# chewie: ^1.5.0 # Cek versi terbaru (sesuaikan dengan video_player)
|
||||
flutter_dotenv: ^5.0.0 # Cek versi terbaru
|
||||
intl: ^0.19.0 # Cek versi terbaru (untuk format tanggal/waktu)
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ flutter:
|
|||
# To add assets to your application, add an assets section, like this:
|
||||
assets:
|
||||
- .env
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
|
|
|
|||
Loading…
Reference in New Issue