| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553 |
- import 'dart:async';
- import 'dart:math' show max, pow;
- import 'dart:typed_data';
- import 'dart:ui' as ui;
- import 'package:flutter/material.dart';
- import 'package:flutter/rendering.dart';
- import 'package:flutter/services.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import 'package:gal/gal.dart';
- import 'package:path_provider/path_provider.dart';
- import 'package:share_plus/share_plus.dart';
- import 'dart:io';
- import 'package:qr_flutter/qr_flutter.dart';
- import '../../../core/config/app_config.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/network/dio_client.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/number_format.dart';
- import '../../../core/utils/symbol_display.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../data/services/auth_service.dart';
- import '../../../providers/auth_provider.dart';
- import '../../../providers/copy_trading_provider.dart';
- import '../../../providers/futures_provider.dart';
- import '../../../providers/spot_provider.dart';
- import '../../widgets/common/app_refresh_indicator.dart';
- import '../../widgets/common/app_shimmer.dart';
- import '../../widgets/common/kline_toolbar_icon.dart';
- import '../../widgets/common/symbol_picker_sheet.dart';
- /// 将仓位 marginMode 字符串(来自后端)映射为 l10n 标签
- String _marginModeLabel(String mode, AppLocalizations l10n) {
- switch (mode) {
- case '分仓':
- return l10n.splitMargin;
- default:
- return l10n.crossMargin;
- }
- }
- /// marginMode → 标签颜色:全仓→蓝,分仓/逐仓→紫
- Color _marginModeColor(String mode) {
- switch (mode) {
- case '分仓':
- case '逐仓':
- return AppColors.rankPurple;
- default:
- return AppColors.tagBlue;
- }
- }
- /// 从合约 symbol 提取基础币种名(BTC/USDT → BTC,ETCUSDT → ETC)
- String _baseCoin(String sym) {
- if (sym.contains('/')) return sym.split('/').first;
- return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- }
- /// 未登录时跳转登录页,返回 false 表示操作被拦截
- bool _requireLogin(BuildContext context, WidgetRef ref) {
- final isLoggedIn = ref.read(isLoggedInProvider);
- if (!isLoggedIn) {
- context.push('/login');
- return false;
- }
- return true;
- }
- /// 对齐原型的确认弹窗:上方消息 + 可选红色副文本,下方取消/确定按钮
- Future<bool> _showFuturesConfirm(
- BuildContext context, {
- required String message,
- String? subMessage,
- String? cancelLabel,
- String? confirmLabel,
- }) async {
- final l10n = AppLocalizations.of(context)!;
- final cancel = cancelLabel ?? l10n.cancelLabel;
- final confirm = confirmLabel ?? l10n.confirmLabel;
- final result = await showDialog<bool>(
- context: context,
- barrierDismissible: true,
- builder: (ctx) {
- final cs = Theme.of(ctx).colorScheme;
- return Dialog(
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
- backgroundColor: cs.surface,
- insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- message,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w600),
- ),
- if (subMessage != null) ...[
- const SizedBox(height: 8),
- Text(
- subMessage,
- style: const TextStyle(
- color: AppColors.fall, fontSize: 13, height: 1.5),
- ),
- ],
- ],
- ),
- ),
- Divider(height: 1, thickness: 0.5, color: cs.outline.withAlpha(80)),
- IntrinsicHeight(
- child: Row(
- children: [
- Expanded(
- child: TextButton(
- onPressed: () => Navigator.of(ctx).pop(false),
- style: TextButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 14),
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.only(
- bottomLeft: Radius.circular(16)),
- ),
- ),
- child: Text(
- cancel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 15),
- ),
- ),
- ),
- VerticalDivider(
- width: 1,
- thickness: 0.5,
- color: cs.outline.withAlpha(80)),
- Expanded(
- child: TextButton(
- onPressed: () => Navigator.of(ctx).pop(true),
- style: TextButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 14),
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.only(
- bottomRight: Radius.circular(16)),
- ),
- ),
- child: Text(
- confirm,
- style: const TextStyle(
- color: AppColors.brand,
- fontSize: 15,
- fontWeight: FontWeight.w700),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- );
- },
- );
- return result == true;
- }
- class FuturesScreen extends ConsumerStatefulWidget {
- const FuturesScreen({
- super.key,
- required this.symbol,
- this.showSpotSwitcher = true,
- });
- final String symbol;
- final bool showSpotSwitcher;
- @override
- ConsumerState<FuturesScreen> createState() => _FuturesScreenState();
- }
- class _FuturesScreenState extends ConsumerState<FuturesScreen> {
- late final ScrollController _scroll;
- int _obRowCount = 7;
- double _obRowH = 22.0; // 订单薄每行高度(动态计算使左右精确对齐)
- double _leftPanelHeight = 490.0; // 左侧面板实测高度
- final _orderPanelKey = GlobalKey<_OrderPanelState>();
- void _onLeftPanelHeight(double h) {
- // countFixedH=150:保守余量,确保 N 行内容不溢出
- // rowH = (h - 150) / (2N):让行高略小于最大值,Spacer 吸收余量
- const countFixedH = 150.0;
- final n = ((h - countFixedH) / 44).floor().clamp(4, 14);
- final rh = ((h - 150.0) / (n * 2)).clamp(18.0, 28.0);
- if (n != _obRowCount ||
- (rh - _obRowH).abs() > 0.1 ||
- h != _leftPanelHeight) {
- setState(() {
- _obRowCount = n;
- _obRowH = rh;
- _leftPanelHeight = h;
- });
- }
- }
- @override
- void initState() {
- super.initState();
- _scroll = ScrollController();
- _scroll.addListener(_onScroll);
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- ref.read(futuresActiveSymbolProvider.notifier).state = widget.symbol;
- if (widget.showSpotSwitcher) {
- ref.read(lastTradingRouteProvider.notifier).state =
- '/futures/${widget.symbol}';
- }
- });
- }
- @override
- void didUpdateWidget(FuturesScreen oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (widget.symbol != oldWidget.symbol) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- ref.read(futuresActiveSymbolProvider.notifier).state = widget.symbol;
- });
- }
- }
- @override
- void dispose() {
- _scroll.removeListener(_onScroll);
- _scroll.dispose();
- super.dispose();
- }
- Future<void> _pushAndPausePolling(BuildContext context, String path) async {
- final notifier = ref.read(futuresProvider(widget.symbol).notifier);
- notifier.stopPolling();
- await context.push(path);
- if (mounted) notifier.resumePolling(widget.symbol);
- }
- void _onScroll() {
- if (_scroll.position.pixels < _scroll.position.maxScrollExtent - 200)
- return;
- final s = ref.read(futuresProvider(widget.symbol));
- if (s.activeTab == FuturesTab.orders) {
- ref.read(futuresProvider(widget.symbol).notifier).loadMoreOrders();
- }
- }
- @override
- Widget build(BuildContext context) {
- final symbol = widget.symbol;
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- return Scaffold(
- appBar: AppBar(
- elevation: 0,
- toolbarHeight: 44,
- titleSpacing: 16,
- title: widget.showSpotSwitcher
- ? _SpotFuturesTabHeader(
- activeIndex: 1,
- onTap: (i) {
- if (i == 0) {
- final spotSym = ref.read(spotActiveSymbolProvider);
- context.go('/spot/${spotSym.isNotEmpty ? spotSym : 'BTCUSDT'}');
- }
- },
- )
- : Text(
- l10n.perpetualContract,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 17,
- fontWeight: FontWeight.w600,
- ),
- ),
- centerTitle: false,
- bottom: PreferredSize(
- preferredSize: const Size.fromHeight(1),
- child: Container(height: 1, color: cs.outline.withAlpha(40)),
- ),
- actions: [
- // K 线图标,点击进入行情详情页
- IconButton(
- icon: KlineToolbarIcon(color: cs.onSurface.withAlpha(180)),
- onPressed: () =>
- _pushAndPausePolling(context, '/market/futures/$symbol'),
- padding: const EdgeInsets.symmetric(horizontal: 8),
- constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
- ),
- ],
- ),
- body: Listener(
- onPointerDown: (_) => FocusScope.of(context).unfocus(),
- child: Builder(builder: (context) {
- final isLoading =
- ref.watch(futuresProvider(symbol).select((s) => s.isLoading));
- if (isLoading) return const _FuturesShimmer();
- return AppRefreshIndicator(
- onRefresh: () =>
- ref.read(futuresProvider(symbol).notifier).refresh(),
- child: SingleChildScrollView(
- controller: _scroll,
- physics: const ClampingScrollPhysics(),
- child: Column(
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Expanded(
- flex: 55,
- child: _SizeReporter(
- onHeight: _onLeftPanelHeight,
- child: DecoratedBox(
- decoration: BoxDecoration(
- border: Border(
- right: BorderSide(
- color: cs.outline.withAlpha(40),
- width: 1,
- ),
- ),
- ),
- child: _OrderPanel(
- key: _orderPanelKey,
- symbol: symbol,
- showSpotSwitcher: widget.showSpotSwitcher,
- ),
- ),
- ),
- ),
- Expanded(
- flex: 45,
- child: SizedBox(
- height: _leftPanelHeight,
- child: RepaintBoundary(
- child: _OrderBookPanel(
- symbol: symbol,
- rowCount: _obRowCount,
- rowHeight: _obRowH,
- onPriceTap: (price) => _orderPanelKey.currentState
- ?.setBookPrice(price),
- ),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- _BottomSection(symbol: symbol),
- const SizedBox(height: 16),
- ],
- ),
- ),
- ); // RefreshIndicator
- }), // Builder
- ),
- );
- }
- }
- class _OrderPanel extends ConsumerStatefulWidget {
- const _OrderPanel({
- super.key,
- required this.symbol,
- required this.showSpotSwitcher,
- });
- final String symbol;
- final bool showSpotSwitcher;
- @override
- ConsumerState<_OrderPanel> createState() => _OrderPanelState();
- }
- class _OrderPanelState extends ConsumerState<_OrderPanel> {
- final _priceController = TextEditingController();
- final _amountController = TextEditingController();
- final _triggerPriceController = TextEditingController();
- final _tpController = TextEditingController();
- final _slController = TextEditingController();
- bool _updatingFromSlider = false;
- bool _priceFilled = false; // 是否已完成首次价格回显
- double _lastPrice = 0; // 最新价缓存,用 ref.listen 维护,不触发 rebuild
- // 可开多/开空最大值快照
- String _maxOpenAmt = '--';
- // 市价单:每 5 秒自动刷新最大可开量
- Timer? _marketRefreshTimer;
- // 监听路由 secondaryAnimation,导航返回时清除焦点
- Animation<double>? _secondaryAnim;
- bool _isCoveredByRoute = false;
- @override
- void initState() {
- super.initState();
- _amountController.addListener(_onAmountChanged);
- // 进入页面后首帧快照最大可开量,并根据当前下单类型决定是否启动定时器
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- _refreshMaxAmount();
- _updateMarketTimer();
- });
- // 价格输入框变化时(非市价单)刷新最大可开量
- _priceController.addListener(_onPriceChanged);
- _triggerPriceController.addListener(_onPriceChanged);
- }
- void _onPriceChanged() {
- final s = ref.read(futuresProvider(widget.symbol));
- if (s.orderType != OrderType.market) _refreshMaxAmount();
- }
- void _refreshMaxAmount() {
- if (!mounted) return;
- final s = ref.read(futuresProvider(widget.symbol));
- setState(() {
- _maxOpenAmt = _calcMaxOpenAmount(s);
- });
- }
- /// 市价单开启 5 秒定时刷新,其他类型取消定时器(由价格输入框变化驱动)
- void _updateMarketTimer() {
- final s = ref.read(futuresProvider(widget.symbol));
- if (s.orderType == OrderType.market) {
- _marketRefreshTimer ??= Timer.periodic(
- const Duration(seconds: 5), (_) => _refreshMaxAmount());
- } else {
- _marketRefreshTimer?.cancel();
- _marketRefreshTimer = null;
- }
- }
- @override
- void didChangeDependencies() {
- super.didChangeDependencies();
- final anim = ModalRoute.of(context)?.secondaryAnimation;
- if (anim != _secondaryAnim) {
- _secondaryAnim?.removeStatusListener(_onSecondaryAnimation);
- _secondaryAnim = anim;
- _secondaryAnim?.addStatusListener(_onSecondaryAnimation);
- }
- }
- void _onSecondaryAnimation(AnimationStatus status) {
- if (status == AnimationStatus.forward ||
- status == AnimationStatus.completed) {
- _isCoveredByRoute = true;
- } else if (status == AnimationStatus.dismissed && _isCoveredByRoute) {
- _isCoveredByRoute = false;
- // postFrameCallback 确保在 Flutter 自动恢复焦点之后再清除
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) FocusManager.instance.primaryFocus?.unfocus();
- });
- }
- }
- @override
- void didUpdateWidget(_OrderPanel oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.symbol != widget.symbol) {
- // 切换币对:延迟到下一帧再清空,重置首次价格回显标记
- _priceFilled = false;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- _priceController.clear();
- _amountController.clear();
- _triggerPriceController.clear();
- _tpController.clear();
- _slController.clear();
- });
- }
- }
- void _onAmountChanged() {
- if (_updatingFromSlider) return;
- final state = ref.read(futuresProvider(widget.symbol));
- final amount = double.tryParse(_amountController.text.replaceAll(',', ''));
- final notifier = ref.read(futuresProvider(widget.symbol).notifier);
- if (amount == null || amount <= 0) {
- notifier.setSliderPercentFromInput(0);
- return;
- }
- final refPrice = _refPrice(state);
- // 限价/计划委托未填价格时,滑块归零但不弹 toast(toast 在点下单时才提示)
- if (refPrice <= 0) {
- notifier.setSliderPercentFromInput(0);
- return;
- }
- final contractSize = state.contractSize > 0 ? state.contractSize : 1.0;
- // ── 平仓模式:以仓位可平量(BTC)为 max ──
- if (state.positionMode == PositionMode.close) {
- final maxBtc = _closeMaxBtc(state);
- if (maxBtc <= 0) return;
- final double pct;
- switch (state.amountUnit) {
- case AmountUnit.btc:
- pct = (amount / maxBtc).clamp(0.0, 1.0);
- case AmountUnit.usdt:
- final maxUsdt = maxBtc * refPrice;
- pct = maxUsdt > 0 ? (amount / maxUsdt).clamp(0.0, 1.0) : 0.0;
- case AmountUnit.lots:
- final maxLots = contractSize > 0 && refPrice > 0
- ? maxBtc * refPrice / contractSize
- : 0.0;
- pct = maxLots > 0 ? (amount / maxLots).clamp(0.0, 1.0) : 0.0;
- }
- notifier.setSliderPercentFromInput(pct);
- return;
- }
- // ── 开仓模式:以可用保证金为 max ──
- final availableMargin = state.accountInfo.availableMargin;
- final leverage = state.leverage;
- if (availableMargin <= 0 || refPrice <= 0) return;
- // maxNotional = 最大名义仓位价值(保证金 × 杠杆)
- final maxNotional = availableMargin * leverage;
- if (maxNotional <= 0) return;
- final double pct;
- switch (state.amountUnit) {
- case AmountUnit.lots:
- if (contractSize <= 0) return;
- final maxL = (maxNotional / contractSize).floor();
- pct = maxL > 0 ? (amount / maxL).clamp(0.0, 1.0) : 0.0;
- case AmountUnit.usdt:
- pct = (amount / maxNotional).clamp(0.0, 1.0);
- case AmountUnit.btc:
- pct = (amount * refPrice / maxNotional).clamp(0.0, 1.0);
- }
- notifier.setSliderPercentFromInput(pct);
- }
- /// 计算数量上限时使用的参考价格。
- /// 返回 0 表示必填价格未填,调用方应提示用户先输入价格。
- /// 市价 → 最新成交价
- /// 限价 / 计划限价 → 价格输入框(未填返回 0)
- /// 计划市价 → 触发价输入框(未填返回 0)
- double _refPrice(FuturesState state) {
- switch (state.orderType) {
- case OrderType.market:
- return state.lastPrice;
- case OrderType.limit:
- case OrderType.conditionalLimit:
- final v = double.tryParse(_priceController.text.replaceAll(',', ''));
- return (v != null && v > 0) ? v : 0.0;
- case OrderType.conditionalMarket:
- final v =
- double.tryParse(_triggerPriceController.text.replaceAll(',', ''));
- return (v != null && v > 0) ? v : 0.0;
- }
- }
- /// 检查参考价格是否有效,无效时弹出提示并返回 false
- bool _checkRefPrice(BuildContext context, FuturesState state) {
- if (_refPrice(state) > 0) return true;
- final l10n = AppLocalizations.of(context)!;
- final hint = state.orderType == OrderType.conditionalMarket
- ? l10n.enterTriggerPrice
- : l10n.enterPrice;
- showTopToast(context, message: hint, backgroundColor: AppColors.fall);
- return false;
- }
- /// 当前符合 symbol 的最大可平量(单位:BTC/基础币),取多空中较大的一方
- double _closeMaxBtc(FuturesState state) {
- final normSym =
- state.symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase();
- return state.positions
- .where((p) => p.symbol.replaceAll('/', '').toUpperCase() == normSym)
- .fold(0.0, (m, p) => p.availableSize > m ? p.availableSize : m);
- }
- @override
- void dispose() {
- _secondaryAnim?.removeStatusListener(_onSecondaryAnimation);
- _amountController.removeListener(_onAmountChanged);
- _priceController.removeListener(_onPriceChanged);
- _triggerPriceController.removeListener(_onPriceChanged);
- _marketRefreshTimer?.cancel();
- _priceController.dispose();
- _amountController.dispose();
- _triggerPriceController.dispose();
- _tpController.dispose();
- _slController.dispose();
- super.dispose();
- }
- /// 订单薄点击回调:将价格填入对应输入框
- /// 计划市价 → 回显到触发价格;其余情况(含计划限价)→ 回显到委托价格
- void setBookPrice(double price) {
- if (!mounted) return;
- final state = ref.read(futuresProvider(widget.symbol));
- final precision = state.pricePrecision;
- final formatted = price.toStringAsFixed(precision);
- if (state.orderType == OrderType.conditionalMarket) {
- _triggerPriceController.text = formatted;
- } else {
- _priceController.text = formatted;
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(widget.symbol);
- final notifier = ref.read(provider.notifier);
- // 切换开仓/平仓模式时,清空数量输入框和滑块
- ref.listen(provider.select((s) => s.positionMode), (prev, next) {
- if (prev != next) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- _amountController.clear();
- _priceController.clear();
- notifier.setSliderPercent(0);
- });
- }
- });
- // 切换下单类型时,清空所有输入框和滑块;
- // 重置 _priceFilled,限价/计划限价会在下一帧自动回显最新价;
- // 同时重新快照最大可开量
- ref.listen(provider.select((s) => s.orderType), (prev, next) {
- if (prev != next) {
- _priceFilled = false;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- FocusManager.instance.primaryFocus?.unfocus();
- _priceController.clear();
- _amountController.clear();
- _triggerPriceController.clear();
- _tpController.clear();
- _slController.clear();
- notifier.setSliderPercent(0);
- _refreshMaxAmount();
- _updateMarketTimer();
- });
- }
- });
- // 账户数据加载完成时刷新最大可开量(进入页面时账户信息可能尚未到达)
- ref.listen(provider.select((s) => s.accountInfo.availableMargin),
- (prev, next) {
- if (prev != next) _refreshMaxAmount();
- });
- // 最新价:仅缓存到字段,不触发 rebuild(消除每 ~120ms 一次的整面板重建)
- // _priceFilled 逻辑也移到此处
- ref.listen<double>(provider.select((s) => s.lastPrice), (_, next) {
- _lastPrice = next;
- if (!_priceFilled && next > 0) {
- _priceFilled = true;
- final precision = ref.read(provider).pricePrecision;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- if (_priceController.text.isEmpty) {
- _priceController.text = next.toStringAsFixed(precision);
- }
- });
- }
- });
- // 首次 build 时同步一次(ref.listen 不回调当前值)
- if (_lastPrice == 0) {
- _lastPrice = ref.read(provider).lastPrice;
- if (!_priceFilled && _lastPrice > 0) {
- _priceFilled = true;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- if (_priceController.text.isEmpty) {
- _priceController.text =
- _lastPrice.toStringAsFixed(ref.read(provider).pricePrecision);
- }
- });
- }
- }
- final change24h = ref.watch(provider.select((s) => s.change24h));
- final isConditionalOrder =
- ref.watch(provider.select((s) => s.isConditionalOrder));
- final showPriceInput = ref.watch(provider.select((s) => s.showPriceInput));
- final isMarketOrder =
- ref.watch(provider.select((s) => s.orderType == OrderType.market));
- final amountUnit = ref.watch(provider.select((s) => s.amountUnit));
- final amountUnitLabelRaw =
- ref.watch(provider.select((s) => s.amountUnitLabel));
- final sliderPercent = ref.watch(provider.select((s) => s.sliderPercent));
- final tpslEnabled = ref.watch(provider.select((s) => s.tpslEnabled));
- final leverage = ref.watch(provider.select((s) => s.leverage));
- final availableMargin =
- ref.watch(provider.select((s) => s.accountInfo.availableMargin));
- final positionMode = ref.watch(provider.select((s) => s.positionMode));
- final isClose = positionMode == PositionMode.close;
- final coinPrecision = ref.watch(provider.select((s) => s.coinPrecision));
- final normSym =
- widget.symbol.replaceAll('/', '').replaceAll('-', '').toUpperCase();
- final longAvail = ref.watch(provider.select((s) => s.positions
- .where((p) =>
- p.symbol.replaceAll('/', '').toUpperCase() == normSym &&
- p.side == OrderSide.long)
- .fold(0.0, (v, p) => v + p.availableSize)));
- final shortAvail = ref.watch(provider.select((s) => s.positions
- .where((p) =>
- p.symbol.replaceAll('/', '').toUpperCase() == normSym &&
- p.side == OrderSide.short)
- .fold(0.0, (v, p) => v + p.availableSize)));
- final coinSymbol = _baseCoin(widget.symbol);
- final pricePrecision = ref.watch(provider.select((s) => s.pricePrecision));
- final amountPrecision =
- ref.watch(provider.select((s) => s.currentAmountPrecision));
- final priceFormatter = _PrecisionInputFormatter(pricePrecision);
- final amountFormatter = _PrecisionInputFormatter(amountPrecision);
- final isLoggedIn = ref.watch(isLoggedInProvider);
- final isSwitchingMode =
- ref.watch(provider.select((s) => s.isSwitchingMode));
- final marginMode = ref.watch(provider.select((s) => s.marginMode));
- // ── 高频复用样式(每次 rebuild 计算一次,复用 N 次)─────────
- final labelStyle =
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11);
- final valueStyle = TextStyle(
- color: cs.onSurface, fontSize: 11, fontWeight: FontWeight.w600);
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── Symbol row(v2:在左侧表单内)──────────────
- GestureDetector(
- onTap: () => _showSymbolPicker(context),
- behavior: HitTestBehavior.opaque,
- child: Padding(
- padding: const EdgeInsets.only(bottom: 4),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Flexible(
- child: Text(
- formatUsdtPairDisplay(widget.symbol),
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 17,
- fontWeight: FontWeight.w700),
- ),
- ),
- Icon(Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153), size: 16),
- const SizedBox(width: 6),
- Text(
- formatChange(change24h),
- style: TextStyle(
- color: AppColors.changeColor(change24h),
- fontSize: 12,
- fontWeight: FontWeight.w500,
- fontFeatures: const [FontFeature.tabularFigures()]),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 8),
- _MarginLeverageRow(symbol: widget.symbol),
- const SizedBox(height: 10),
- // TODO: 平仓 tab 功能暂时隐藏,待接口完善后恢复
- // _PositionModePills(symbol: widget.symbol),
- // const SizedBox(height: 10),
- _OrderTypeDropdown(symbol: widget.symbol),
- const SizedBox(height: 8),
- if (isConditionalOrder) ...[
- _LargeInput(
- controller: _triggerPriceController,
- label: AppLocalizations.of(context)!.triggerPrice,
- unit: 'USDT',
- suffixDropdown: AppLocalizations.of(context)!.markLabel,
- inputFormatters: [priceFormatter],
- ),
- const SizedBox(height: 8),
- ],
- if (isMarketOrder) ...[
- // 市价委托:显示只读"市价"占位框
- Builder(builder: (context) {
- final cs = Theme.of(context).colorScheme;
- return Container(
- height: 52,
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(8),
- borderRadius: BorderRadius.circular(8),
- ),
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: Row(
- children: [
- Expanded(
- child: Text(
- AppLocalizations.of(context)!.marketPrice,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 15,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- Text(
- 'USDT',
- style: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 13,
- ),
- ),
- ],
- ),
- );
- }),
- const SizedBox(height: 8),
- ] else if (showPriceInput) ...[
- _LargeInput(
- controller: _priceController,
- label: AppLocalizations.of(context)!.priceLabel2,
- unit: 'USDT',
- inputFormatters: [priceFormatter],
- ),
- const SizedBox(height: 8),
- ],
- // ── 数量输入 ──
- _LargeInput(
- key: const ValueKey('amount_input'),
- controller: _amountController,
- label: AppLocalizations.of(context)!.quantityLabel,
- unit: amountUnit == AmountUnit.lots
- ? AppLocalizations.of(context)!.lotsLabel
- : amountUnitLabelRaw,
- showUnitDropdown: true,
- onUnitTap: () => _showAmountUnitSheet(context, notifier),
- inputFormatters: [amountFormatter],
- ),
- const SizedBox(height: 10),
- // ── 百分比滑动条 ──
- _PercentSlider(
- percent: sliderPercent,
- enabled: true,
- onChanged: (pct) {
- // 始终从 ref.read 取最新状态,避免闭包捕获旧值导致联动失效
- final s = ref.read(futuresProvider(widget.symbol));
- // 限价/计划委托未填价格时拦截,提示用户
- if (pct > 0 && !_checkRefPrice(context, s)) return;
- notifier.setSliderPercent(pct);
- _updatingFromSlider = true;
- if (pct == 0) {
- _amountController.clear();
- } else {
- final contractSize = s.contractSize > 0 ? s.contractSize : 1.0;
- final curPrice = _refPrice(s);
- // ── 平仓模式:以仓位可平量(BTC)为基准 ──
- if (s.positionMode == PositionMode.close) {
- final maxBtc = _closeMaxBtc(s);
- if (maxBtc > 0) {
- switch (s.amountUnit) {
- case AmountUnit.btc:
- final rawBtcClose = maxBtc * pct;
- final btcCloseFactor =
- pow(10, s.coinPrecision).toDouble();
- final truncBtcClose =
- (rawBtcClose * btcCloseFactor).truncateToDouble() /
- btcCloseFactor;
- _amountController.text =
- truncBtcClose.toStringAsFixed(s.coinPrecision);
- case AmountUnit.usdt:
- final rawCloseUsdt = maxBtc * curPrice * pct;
- final closeDisplayDp =
- s.usdtPrecision < 2 ? s.usdtPrecision : 2;
- final closeFactor = pow(10, closeDisplayDp).toDouble();
- final flooredCloseUsdt =
- (rawCloseUsdt * closeFactor).floor() / closeFactor;
- _amountController.text =
- flooredCloseUsdt.toStringAsFixed(closeDisplayDp);
- case AmountUnit.lots:
- if (contractSize > 0 && curPrice > 0) {
- final maxLots = maxBtc * curPrice / contractSize;
- final lots = maxLots * pct;
- _amountController.text = s.volScale > 0
- ? lots.toStringAsFixed(s.volScale)
- : lots.floor().toString();
- }
- }
- }
- } else {
- // ── 开仓模式:以可用保证金为基准 ──
- final curMargin = s.accountInfo.availableMargin;
- final curLeverage = s.leverage;
- if (curMargin > 0 && curPrice > 0) {
- final feeMultiplier = 1 + s.openFeeRate * curLeverage;
- final effectiveMargin = curMargin / feeMultiplier * pct;
- final maxNotional = effectiveMargin * curLeverage;
- switch (s.amountUnit) {
- case AmountUnit.usdt:
- final displayDp =
- s.usdtPrecision < 2 ? s.usdtPrecision : 2;
- final factor = pow(10, displayDp).toDouble();
- final flooredUsdt =
- (maxNotional * factor).floor() / factor;
- _amountController.text =
- flooredUsdt.toStringAsFixed(displayDp);
- case AmountUnit.btc:
- final rawBtcOpen = maxNotional / curPrice;
- final btcOpenFactor =
- pow(10, s.coinPrecision).toDouble();
- final truncBtcOpen =
- (rawBtcOpen * btcOpenFactor).truncateToDouble() /
- btcOpenFactor;
- _amountController.text =
- truncBtcOpen.toStringAsFixed(s.coinPrecision);
- case AmountUnit.lots:
- if (contractSize <= 0) break;
- final maxL =
- (effectiveMargin * curLeverage / contractSize)
- .floor();
- _amountController.text = s.volScale > 0
- ? maxL.toStringAsFixed(s.volScale)
- : maxL.toString();
- }
- }
- }
- }
- _updatingFromSlider = false;
- },
- ),
- ...[
- const SizedBox(height: 10),
- Row(
- children: [
- Text('${AppLocalizations.of(context)!.availableLabel} ',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12)),
- Text(
- '${formatAmount(availableMargin)} USDT',
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 12,
- fontWeight: FontWeight.w600),
- ),
- const Spacer(),
- GestureDetector(
- onTap: () async {
- final notifier =
- ref.read(futuresProvider(widget.symbol).notifier);
- notifier.stopPolling();
- await context.push('/asset/transfer');
- if (context.mounted) notifier.resumePolling(widget.symbol);
- },
- child: Icon(Icons.swap_horiz,
- color: cs.onSurface.withAlpha(153), size: 16),
- ),
- ],
- ),
- const SizedBox(height: 6),
- ],
- // 可开多 + 保证金(开多按钮上方)
- if (isLoggedIn && !isClose) ...[
- Row(children: [
- Text('${AppLocalizations.of(context)!.canOpenLong} ',
- style: labelStyle),
- Flexible(
- child: Text('$_maxOpenAmt $amountUnitLabelRaw',
- style: valueStyle, overflow: TextOverflow.ellipsis)),
- ]),
- const SizedBox(height: 2),
- ListenableBuilder(
- listenable: Listenable.merge([
- _priceController,
- _amountController,
- _triggerPriceController
- ]),
- builder: (context, _) {
- final marginStr = _calcMargin(_lastPrice, leverage);
- return Text(
- AppLocalizations.of(context)!.marginBalance(marginStr),
- style: labelStyle);
- },
- ),
- const SizedBox(height: 6),
- ],
- SizedBox(
- width: double.infinity,
- height: 40,
- child: ElevatedButton(
- onPressed: isSwitchingMode
- ? null
- : isLoggedIn
- ? () => isClose
- ? _closeOrder(context, notifier, OrderSide.long)
- : _placeOrder(context, notifier, OrderSide.long)
- : () => context.push('/login'),
- style: ElevatedButton.styleFrom(
- backgroundColor: isClose ? AppColors.fall : AppColors.rise,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: Text(
- isClose
- ? AppLocalizations.of(context)!.closeLong
- : AppLocalizations.of(context)!.openLong,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 15,
- fontWeight: FontWeight.w600),
- ),
- ),
- ),
- const SizedBox(height: 8),
- // 可开空 + 保证金(开空按钮上方)
- if (isLoggedIn && !isClose) ...[
- Row(children: [
- Text('${AppLocalizations.of(context)!.canOpenShort} ',
- style: labelStyle),
- Flexible(
- child: Text('$_maxOpenAmt $amountUnitLabelRaw',
- style: valueStyle, overflow: TextOverflow.ellipsis)),
- ]),
- const SizedBox(height: 2),
- ListenableBuilder(
- listenable: Listenable.merge([
- _priceController,
- _amountController,
- _triggerPriceController
- ]),
- builder: (context, _) {
- final marginStr = _calcMargin(_lastPrice, leverage);
- return Text(
- AppLocalizations.of(context)!.marginBalance(marginStr),
- style: labelStyle);
- },
- ),
- const SizedBox(height: 6),
- ],
- SizedBox(
- width: double.infinity,
- height: 40,
- child: ElevatedButton(
- onPressed: isSwitchingMode
- ? null
- : isLoggedIn
- ? () => isClose
- ? _closeOrder(context, notifier, OrderSide.short)
- : _placeOrder(context, notifier, OrderSide.short)
- : () => context.push('/login'),
- style: ElevatedButton.styleFrom(
- backgroundColor: isClose ? AppColors.rise : AppColors.fall,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: Text(
- isClose
- ? AppLocalizations.of(context)!.closeShort
- : AppLocalizations.of(context)!.openShort,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 15,
- fontWeight: FontWeight.w600),
- ),
- ),
- ),
- const SizedBox(height: 12),
- ],
- ),
- );
- }
- Future<void> _placeOrder(
- BuildContext context, FuturesNotifier notifier, OrderSide side) async {
- if (!_requireLogin(context, ref)) return;
- // formatPrice 会加千分符,解析时需先去掉逗号
- final price = double.tryParse(_priceController.text.replaceAll(',', ''));
- final amount = double.tryParse(_amountController.text.replaceAll(',', ''));
- final triggerPrice =
- double.tryParse(_triggerPriceController.text.replaceAll(',', ''));
- final tpPrice = double.tryParse(_tpController.text.replaceAll(',', ''));
- final slPrice = double.tryParse(_slController.text.replaceAll(',', ''));
- final err = await notifier.placeOpenOrder(
- side: side,
- entrustPrice: price,
- triggerPrice: triggerPrice,
- volume: amount,
- tpPrice: tpPrice,
- slPrice: slPrice,
- );
- if (!context.mounted) return;
- if (err == null) {
- // 下单成功后清空输入框
- _priceController.clear();
- _amountController.clear();
- _triggerPriceController.clear();
- _tpController.clear();
- _slController.clear();
- notifier.setSliderPercent(0);
- }
- final l10n0 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: err != null
- ? (resolveProviderError(err, l10n0) ?? err)
- : l10n0.orderSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- Future<void> _closeOrder(
- BuildContext context, FuturesNotifier notifier, OrderSide side) async {
- if (!_requireLogin(context, ref)) return;
- final price = double.tryParse(_priceController.text.replaceAll(',', ''));
- final triggerPrice =
- double.tryParse(_triggerPriceController.text.replaceAll(',', ''));
- // 将用户输入量转为基础币(BTC)后传入;0/null 表示全仓平
- final inputAmount =
- double.tryParse(_amountController.text.replaceAll(',', ''));
- final s = ref.read(futuresProvider(widget.symbol));
- final double? volumeInBtc;
- if (inputAmount != null && inputAmount > 0) {
- final cs = s.contractSize > 0 ? s.contractSize : 1.0;
- final isMarketClose = s.orderType == OrderType.market;
- final ep =
- (!isMarketClose && price != null && price > 0) ? price : s.lastPrice;
- volumeInBtc = switch (s.amountUnit) {
- AmountUnit.btc => inputAmount,
- AmountUnit.usdt => ep > 0 ? inputAmount / ep : null,
- AmountUnit.lots => ep > 0 ? inputAmount * cs / ep : null,
- };
- } else {
- volumeInBtc = null; // 全仓平
- }
- final isConditional = s.orderType == OrderType.conditionalMarket ||
- s.orderType == OrderType.conditionalLimit;
- String? err;
- if (isConditional && triggerPrice != null && triggerPrice > 0) {
- err = await notifier.closeConditionalByDirection(
- side,
- triggerPrice: triggerPrice,
- volume: volumeInBtc,
- entrustPrice: (price != null && price > 0) ? price : 0,
- );
- } else {
- final isMarket = s.orderType == OrderType.market;
- err = await notifier.closeByDirection(
- side,
- price: isMarket ? null : price,
- volume: volumeInBtc,
- );
- }
- if (!context.mounted) return;
- if (err == null) {
- _amountController.clear();
- _triggerPriceController.clear();
- notifier.setSliderPercent(0);
- }
- final l10n1 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n1) ?? l10n1.closeOrderSubmitted,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- String _calcMargin(double lastPrice, double leverage) {
- final state = ref.read(futuresProvider(widget.symbol));
- // 计划市价用触发价,计划限价/限价用价格输入框,市价用最新价
- final double price;
- if (state.orderType == OrderType.conditionalMarket) {
- price =
- double.tryParse(_triggerPriceController.text.replaceAll(',', '')) ??
- lastPrice;
- } else {
- price = double.tryParse(_priceController.text.replaceAll(',', '')) ??
- lastPrice;
- }
- final amount =
- double.tryParse(_amountController.text.replaceAll(',', '')) ?? 0;
- if (amount == 0 || leverage == 0) return '0.00';
- final contractSize = state.contractSize > 0 ? state.contractSize : 1.0;
- // 换算为 USDT 名义价值再除以杠杆
- // 张: lots × contractSize(USDT/张) = USDT(面值已是 USDT,无需再乘价格)
- // USDT: 直接
- // BTC/ETH/ETC: 数量 × price = USDT
- final availableMargin =
- ref.read(futuresProvider(widget.symbol)).accountInfo.availableMargin;
- final double rawMargin;
- switch (state.amountUnit) {
- case AmountUnit.usdt:
- // USDT 输入为名义仓位价值,除以杠杆得保证金
- rawMargin = amount / leverage;
- case AmountUnit.lots:
- // 张 × contractSize = 名义价值,再除杠杆得保证金
- rawMargin = amount * contractSize / leverage;
- case AmountUnit.btc:
- // 基础币 × 价格 = 名义价值,再除杠杆得保证金
- rawMargin = amount * price / leverage;
- }
- // clamp 后再截断(floor),防止 formatAmount 进位后显示值 > 可用
- final clamped =
- availableMargin > 0 ? rawMargin.clamp(0.0, availableMargin) : rawMargin;
- final margin = (clamped * 100).floorToDouble() / 100;
- return formatAmount(margin, decimals: 2);
- }
- /// 可开多/可开空数量,与老版 Android 公式一致:
- /// availableBalance / (((1/leverage) + openFee) * lastPrice)
- String _calcMaxOpenAmount(FuturesState state) {
- final refPrice = _refPrice(state);
- final avail = state.accountInfo.availableMargin;
- final leverage = state.leverage;
- final cs = state.contractSize > 0 ? state.contractSize : 1.0;
- if (avail <= 0 || refPrice <= 0) return '--';
- final feeMultiplier = 1 + state.openFeeRate * leverage;
- final maxNotional = avail / feeMultiplier * leverage;
- switch (state.amountUnit) {
- case AmountUnit.usdt:
- return formatAmount(maxNotional);
- case AmountUnit.lots:
- return (maxNotional / cs).floor().toString();
- case AmountUnit.btc:
- return formatAmount(maxNotional / refPrice,
- decimals: state.coinPrecision);
- }
- }
- /// 可平量(基于仓位可平 size,换算为当前单位)
- String _calcMaxCloseAmount(FuturesState state, double availSize) {
- final refPrice = _refPrice(state);
- final cs = state.contractSize > 0 ? state.contractSize : 1.0;
- switch (state.amountUnit) {
- case AmountUnit.btc:
- return formatAmount(availSize, decimals: state.coinPrecision);
- case AmountUnit.usdt:
- return refPrice > 0 ? formatAmount(availSize * refPrice) : '--';
- case AmountUnit.lots:
- return (cs > 0 && refPrice > 0)
- ? (availSize * refPrice / cs).floor().toString()
- : '--';
- }
- }
- void _showSymbolPicker(BuildContext context) {
- FocusScope.of(context).unfocus();
- final targetBasePath = widget.showSpotSwitcher ? '/futures' : '/contracts';
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SymbolPickerSheet(
- currentSymbol: widget.symbol,
- visibleTabs: const [SymbolPickerTab.futures],
- onSelected: (newSymbol) {
- Navigator.pop(sheetCtx);
- context.go('$targetBasePath/$newSymbol');
- },
- onSpotSelected: (newSymbol) {
- Navigator.pop(sheetCtx);
- context.go('$targetBasePath/$newSymbol');
- },
- ),
- );
- }
- void _showAmountUnitSheet(BuildContext context, FuturesNotifier notifier) {
- FocusScope.of(context).unfocus();
- final cs = Theme.of(context).colorScheme;
- final coinLabel = ref.read(futuresProvider(widget.symbol).select((s) {
- if (s.coinSymbol.isNotEmpty) return s.coinSymbol;
- final base =
- widget.symbol.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- return base.isNotEmpty ? base : 'BTC';
- }));
- final currentUnit =
- ref.read(futuresProvider(widget.symbol).select((s) => s.amountUnit));
- // 各单位的标题和描述
- final l10n = AppLocalizations.of(context)!;
- String unitTitle(AmountUnit unit) {
- switch (unit) {
- case AmountUnit.btc:
- return l10n.contractUnitCoin(coinLabel);
- case AmountUnit.usdt:
- return l10n.contractUnitUsdt;
- case AmountUnit.lots:
- return l10n.contractUnitSheets;
- }
- }
- String unitDesc(AmountUnit unit) {
- switch (unit) {
- case AmountUnit.btc:
- return l10n.contractUnitHintCoin(coinLabel);
- case AmountUnit.usdt:
- return l10n.contractUnitHintUsdt;
- case AmountUnit.lots:
- return l10n.contractUnitHintSheets;
- }
- }
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: cs.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (_) => StatefulBuilder(
- builder: (ctx, setState) {
- AmountUnit selected = currentUnit;
- return SafeArea(
- child: SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- l10n.contractUnitSetting,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 16),
- for (final unit in AmountUnit.values) ...[
- GestureDetector(
- onTap: () {
- notifier.setAmountUnit(unit);
- // 单位切换后清空输入和滑块,避免旧值被误用
- _amountController.clear();
- notifier.setSliderPercent(0);
- Navigator.pop(ctx);
- },
- child: Container(
- width: double.infinity,
- padding: const EdgeInsets.symmetric(
- horizontal: 14, vertical: 14),
- decoration: BoxDecoration(
- border: Border.all(
- color: selected == unit
- ? cs.primary
- : cs.outline.withAlpha(80),
- width: selected == unit ? 1.5 : 1,
- ),
- borderRadius: BorderRadius.circular(8),
- color: selected == unit
- ? cs.primary.withAlpha(15)
- : Colors.transparent,
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- unitTitle(unit),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- unitDesc(unit),
- style: TextStyle(
- color: cs.onSurface.withAlpha(140),
- fontSize: 12,
- ),
- ),
- ],
- ),
- ),
- ),
- if (unit != AmountUnit.lots) const SizedBox(height: 10),
- ],
- ],
- ),
- ),
- );
- },
- ),
- );
- }
- }
- class _MarginLeverageRow extends ConsumerWidget {
- const _MarginLeverageRow({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(symbol);
- final marginMode = ref.watch(provider.select((s) => s.marginMode));
- final leverage = ref.watch(provider.select((s) => s.leverage));
- final leverageMin = ref.watch(provider.select((s) => s.leverageMin));
- final leverageMax = ref.watch(provider.select((s) => s.leverageMax));
- final leverageOptions =
- ref.watch(provider.select((s) => s.leverageOptions));
- final isDiscrete = ref.watch(provider.select((s) => s.isDiscreteLeverage));
- final notifier = ref.read(provider.notifier);
- final isLoggedIn = ref.watch(isLoggedInProvider);
- final isTrader = ref.watch(copyTradingProvider.select((s) => s.isTrader));
- return Row(
- children: [
- Builder(builder: (ctx) {
- final isDark = Theme.of(ctx).brightness == Brightness.dark;
- return GestureDetector(
- onTap: () => isLoggedIn
- ? _showMarginModeSheet(context, marginMode, notifier,
- isTrader: isTrader)
- : context.push('/login'),
- child: Container(
- height: 26,
- padding: const EdgeInsets.symmetric(horizontal: 7),
- decoration: BoxDecoration(
- color: isDark
- ? AppColors.darkBgTertiary
- : AppColors.lightBgTertiary,
- borderRadius: BorderRadius.circular(4),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- switch (marginMode) {
- MarginMode.cross =>
- AppLocalizations.of(context)!.crossMargin,
- MarginMode.isolated =>
- AppLocalizations.of(context)!.isolatedMargin,
- MarginMode.split =>
- AppLocalizations.of(context)!.splitMargin,
- },
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 13,
- fontWeight: FontWeight.w700)),
- Icon(Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153), size: 13),
- ],
- ),
- ),
- );
- }),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () {
- if (!isLoggedIn) {
- context.push('/login');
- return;
- }
- _showLeverageSheet(context, leverage, leverageMin, leverageMax,
- leverageOptions, isDiscrete, notifier);
- },
- child: Container(
- height: 26,
- padding: const EdgeInsets.symmetric(horizontal: 7),
- decoration: const BoxDecoration(
- color: AppColors.leverageGoldBg,
- borderRadius: BorderRadius.all(Radius.circular(4)),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- '${leverage.toInt()}X',
- style: const TextStyle(
- color: AppColors.leverageGold,
- fontSize: 13,
- fontWeight: FontWeight.w700,
- fontFeatures: [FontFeature.tabularFigures()],
- ),
- ),
- const Icon(Icons.keyboard_arrow_down,
- color: AppColors.leverageGold, size: 13),
- ],
- ),
- ),
- ),
- ],
- );
- }
- void _showLeverageSheet(
- BuildContext context,
- double leverage,
- int leverageMin,
- int leverageMax,
- List<int> leverageOptions,
- bool isDiscrete,
- FuturesNotifier notifier) {
- FocusScope.of(context).unfocus();
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (_) => _LeverageSheet(
- symbol: symbol,
- current: leverage,
- leverageMin: leverageMin,
- leverageMax: leverageMax,
- leverageOptions: leverageOptions,
- isDiscrete: isDiscrete,
- onChanged: notifier.setLeverage,
- ),
- );
- }
- void _showMarginModeSheet(
- BuildContext context, MarginMode current, FuturesNotifier notifier,
- {bool isTrader = false}) {
- FocusScope.of(context).unfocus();
- final cs = Theme.of(context).colorScheme;
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: cs.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SafeArea(
- child: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
- child: Text(AppLocalizations.of(context)!.marginMode,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600)),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
- child: Column(
- children: [
- for (final entry in [
- (
- MarginMode.cross,
- AppLocalizations.of(context)!.crossMargin,
- AppLocalizations.of(context)!.crossMarginDesc,
- _MarginModeIcon.cross
- ),
- // 带单员不支持分仓,隐藏分仓选项
- if (!isTrader)
- (
- MarginMode.split,
- AppLocalizations.of(context)!.splitMargin,
- AppLocalizations.of(context)!.splitMarginDesc,
- _MarginModeIcon.split
- ),
- ]) ...[
- GestureDetector(
- onTap: () async {
- Navigator.pop(sheetCtx);
- final err = await notifier.setMarginMode(entry.$1);
- if (err != null && context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!
- .switchMarginModeFailed);
- }
- },
- child: Container(
- width: double.infinity,
- padding: const EdgeInsets.symmetric(
- horizontal: 14, vertical: 12),
- decoration: BoxDecoration(
- border: Border.all(
- color:
- current == entry.$1 ? cs.onSurface : cs.outline,
- width: current == entry.$1 ? 1.5 : 1,
- ),
- borderRadius: BorderRadius.circular(10),
- color: Colors.transparent,
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _MarginModeIconWidget(type: entry.$4),
- const SizedBox(width: 12),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Text(entry.$2,
- style: TextStyle(
- color: cs.onSurface,
- fontWeight: FontWeight.w600,
- fontSize: 14)),
- if (current == entry.$1) ...[
- const SizedBox(width: 6),
- Icon(Icons.check_circle,
- size: 16, color: cs.onSurface),
- ],
- ],
- ),
- const SizedBox(height: 4),
- Text(entry.$3,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 12,
- height: 1.4)),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 10),
- ],
- ],
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
- child: Center(
- child: Text(
- AppLocalizations.of(context)!.marginModeNote,
- style: TextStyle(
- color:
- Theme.of(sheetCtx).colorScheme.onSurface.withAlpha(120),
- fontSize: 11,
- decoration: TextDecoration.underline,
- decorationColor:
- Theme.of(sheetCtx).colorScheme.onSurface.withAlpha(120),
- ),
- ),
- ),
- ),
- ],
- )),
- ),
- );
- }
- }
- class _LeverageSheet extends StatefulWidget {
- const _LeverageSheet({
- required this.symbol,
- required this.current,
- required this.onChanged,
- this.leverageMin = 1,
- this.leverageMax = 125,
- this.leverageOptions = const [],
- this.isDiscrete = false,
- });
- final String symbol;
- final double current;
- final Future<String?> Function(double) onChanged;
- final int leverageMin;
- final int leverageMax;
- final List<int> leverageOptions;
- /// true=分离倍数(只能选指定档位)false=连续倍数(区间内任意值)
- final bool isDiscrete;
- @override
- State<_LeverageSheet> createState() => _LeverageSheetState();
- }
- class _LeverageSheetState extends State<_LeverageSheet> {
- late double _value;
- bool _loading = false;
- /// 预设档位:优先用接口返回的 leverageOptions,否则用默认
- List<int> get _presets {
- if (widget.leverageOptions.isNotEmpty) return widget.leverageOptions;
- return [1, 5, 10, 30, 50, 75, 100, 125]
- .where((v) => v >= widget.leverageMin && v <= widget.leverageMax)
- .toList();
- }
- @override
- void initState() {
- super.initState();
- final clamped = widget.current
- .clamp(widget.leverageMin.toDouble(), widget.leverageMax.toDouble());
- if (widget.isDiscrete && widget.leverageOptions.isNotEmpty) {
- // 离散模式:对齐到最近档位
- final cur = clamped.toInt();
- final nearest = widget.leverageOptions.reduce(
- (a, b) => (a - cur).abs() <= (b - cur).abs() ? a : b,
- );
- _value = nearest.toDouble();
- } else {
- _value = clamped;
- }
- }
- void _step(int delta) {
- if (widget.isDiscrete && _presets.isNotEmpty) {
- // 离散模式:跳到相邻档位
- final cur = _value.toInt();
- final idx = _presets.indexOf(cur);
- int nextIdx;
- if (idx < 0) {
- // 当前值不在档位中,找最近的
- nextIdx = delta > 0
- ? _presets.indexWhere((v) => v > cur)
- : _presets.lastIndexWhere((v) => v < cur);
- } else {
- nextIdx = idx + delta.sign;
- }
- if (nextIdx >= 0 && nextIdx < _presets.length) {
- setState(() => _value = _presets[nextIdx].toDouble());
- }
- } else {
- final next = (_value + delta)
- .clamp(widget.leverageMin.toDouble(), widget.leverageMax.toDouble());
- setState(() => _value = next);
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return SafeArea(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // ── 顶部标题栏 ──────────────────────────────────
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 16),
- child: Row(
- children: [
- const SizedBox(width: 32),
- Expanded(
- child: Column(
- children: [
- Text(
- AppLocalizations.of(context)!
- .symbolPerpetual(widget.symbol),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w700,
- ),
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 2),
- Text(
- AppLocalizations.of(context)!.adjustLeverage,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 12,
- ),
- textAlign: TextAlign.center,
- ),
- ],
- ),
- ),
- SizedBox(
- width: 32,
- child: GestureDetector(
- onTap: () => Navigator.pop(context),
- child: Icon(Icons.close,
- size: 20, color: cs.onSurface.withAlpha(153)),
- ),
- ),
- ],
- ),
- ),
- // ── - | 倍数 | + ────────────────────────────────
- Container(
- height: 60,
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgTertiary : Colors.white,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: cs.outline.withAlpha(80)),
- ),
- child: Row(
- children: [
- _LeverageStepBtn(
- label: '−',
- onTap: () => _step(-1),
- onLongPress: () => _step(-5),
- isLeft: true,
- ),
- Container(
- width: 1,
- color:
- Theme.of(context).colorScheme.outline.withAlpha(80)),
- Expanded(
- child: Center(
- child: Text(
- '${_value.toInt()}x',
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 26,
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- ),
- Container(
- width: 1,
- color:
- Theme.of(context).colorScheme.outline.withAlpha(80)),
- // + 按钮
- _LeverageStepBtn(
- label: '+',
- onTap: () => _step(1),
- onLongPress: () => _step(5),
- isLeft: false,
- ),
- ],
- ),
- ),
- const SizedBox(height: 20),
- // ── 滑轨(连续模式显示,离散模式隐藏)────────────
- if (!widget.isDiscrete) ...[
- SliderTheme(
- data: SliderTheme.of(context).copyWith(
- activeTrackColor: AppColors.brand,
- inactiveTrackColor: cs.outline.withAlpha(50),
- thumbColor: Colors.white,
- thumbShape:
- const RoundSliderThumbShape(enabledThumbRadius: 10),
- overlayShape:
- const RoundSliderOverlayShape(overlayRadius: 18),
- overlayColor: cs.onSurface.withAlpha(20),
- trackHeight: 3,
- ),
- child: Slider(
- value: _value,
- min: widget.leverageMin.toDouble(),
- max: widget.leverageMax.toDouble(),
- divisions:
- (widget.leverageMax - widget.leverageMin).clamp(1, 999),
- onChanged: (v) => setState(() => _value = v.roundToDouble()),
- ),
- ),
- const SizedBox(height: 12),
- ],
- // ── 档位按钮(两种模式都显示,离散时为唯一选择方式)
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: _presets.map((v) {
- final active = _value.toInt() == v;
- return GestureDetector(
- onTap: () => setState(() => _value = v.toDouble()),
- child: Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
- decoration: BoxDecoration(
- color:
- active ? AppColors.brand : cs.outline.withAlpha(25),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(
- '${v}x',
- style: TextStyle(
- color:
- active ? Colors.black : cs.onSurface.withAlpha(153),
- fontSize: 12,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- );
- }).toList(),
- ),
- const SizedBox(height: 24),
- // ── 确定按钮 ─────────────────────────────────────
- SizedBox(
- width: double.infinity,
- height: 52,
- child: ElevatedButton(
- onPressed: _loading
- ? null
- : () async {
- setState(() => _loading = true);
- final err = await widget.onChanged(_value);
- if (!context.mounted) return;
- setState(() => _loading = false);
- showTopToast(
- context,
- message: err ??
- AppLocalizations.of(context)!
- .leverageAdjustedMsg(_value.toInt()),
- backgroundColor:
- err != null ? AppColors.fall : AppColors.rise,
- );
- if (err == null) Navigator.pop(context);
- },
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(26)),
- ),
- child: _loading
- ? SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2, color: Colors.black),
- )
- : Text(AppLocalizations.of(context)!.confirmLabel,
- style: TextStyle(
- color: Colors.black,
- fontSize: 16,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
- /// 杠杆调节 -/+ 按钮
- class _LeverageStepBtn extends StatelessWidget {
- const _LeverageStepBtn({
- required this.label,
- required this.onTap,
- required this.onLongPress,
- this.isLeft = true,
- });
- final String label;
- final VoidCallback onTap;
- final VoidCallback onLongPress;
- final bool isLeft;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final radius = Radius.circular(11);
- return GestureDetector(
- onTap: onTap,
- onLongPress: onLongPress,
- child: Container(
- width: 60,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: cs.outline.withAlpha(25),
- borderRadius: isLeft
- ? BorderRadius.only(topLeft: radius, bottomLeft: radius)
- : BorderRadius.only(topRight: radius, bottomRight: radius),
- ),
- child: Text(
- label,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 24,
- fontWeight: FontWeight.w400,
- ),
- ),
- ),
- );
- }
- }
- class _PositionModePills extends ConsumerWidget {
- const _PositionModePills({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(symbol);
- final positionMode = ref.watch(provider.select((s) => s.positionMode));
- final notifier = ref.read(provider.notifier);
- final isOpen = positionMode == PositionMode.open;
- return Container(
- height: 30,
- padding: const EdgeInsets.all(2),
- decoration: BoxDecoration(
- color: cs.outline.withAlpha(80),
- borderRadius: BorderRadius.circular(999),
- ),
- child: Row(
- children: [
- _SegPill(
- label: AppLocalizations.of(context)!.openLabel,
- active: isOpen,
- activeColor: AppColors.rise,
- onTap: () => notifier.setPositionMode(PositionMode.open),
- ),
- _SegPill(
- label: AppLocalizations.of(context)!.closeLabel,
- active: !isOpen,
- activeColor: AppColors.fall,
- onTap: () => notifier.setPositionMode(PositionMode.close),
- ),
- ],
- ),
- );
- }
- }
- class _SegPill extends StatelessWidget {
- const _SegPill({
- required this.label,
- required this.active,
- required this.activeColor,
- required this.onTap,
- });
- final String label;
- final bool active;
- final Color activeColor;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Expanded(
- child: GestureDetector(
- onTap: onTap,
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 150),
- decoration: BoxDecoration(
- color: active ? activeColor : Colors.transparent,
- borderRadius: BorderRadius.circular(999),
- ),
- alignment: Alignment.center,
- child: Text(
- label,
- style: TextStyle(
- color: active ? Colors.white : cs.onSurface.withAlpha(153),
- fontSize: 12,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- );
- }
- }
- class _OrderTypeDropdown extends ConsumerWidget {
- const _OrderTypeDropdown({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(symbol);
- final orderType = ref.watch(provider.select((s) => s.orderType));
- final isClose =
- ref.watch(provider.select((s) => s.positionMode == PositionMode.close));
- final notifier = ref.read(provider.notifier);
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return GestureDetector(
- onTap: () => _showOrderTypeMenu(context, orderType, isClose, notifier),
- child: Container(
- height: 36,
- padding: const EdgeInsets.symmetric(horizontal: 10),
- decoration: BoxDecoration(
- color:
- isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- () {
- final l10n = AppLocalizations.of(context)!;
- switch (orderType) {
- case OrderType.market:
- return l10n.marketOrder;
- case OrderType.limit:
- return l10n.limitOrder;
- case OrderType.conditionalMarket:
- return l10n.conditionalMarketOrder;
- case OrderType.conditionalLimit:
- return l10n.conditionalLimitOrder;
- }
- }(),
- style: TextStyle(color: cs.onSurface, fontSize: 13),
- ),
- Icon(Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153), size: 18),
- ],
- ),
- ),
- );
- }
- void _showOrderTypeMenu(BuildContext context, OrderType currentType,
- bool isClose, FuturesNotifier notifier) {
- FocusScope.of(context).unfocus();
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final types = [
- (OrderType.market, l10n.marketOrder),
- (OrderType.limit, l10n.limitOrder),
- if (!isClose) (OrderType.conditionalMarket, l10n.conditionalMarketOrder),
- if (!isClose) (OrderType.conditionalLimit, l10n.conditionalLimitOrder),
- ];
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor: cs.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: types
- .map((t) => GestureDetector(
- onTap: () {
- notifier.setOrderType(t.$1);
- Navigator.pop(sheetCtx);
- },
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 16, vertical: 14),
- child: Row(
- children: [
- Expanded(
- child: Text(t.$2,
- style: TextStyle(
- color: currentType == t.$1
- ? AppColors.brand
- : cs.onSurface,
- fontSize: 14)),
- ),
- if (currentType == t.$1)
- const Icon(Icons.check,
- color: AppColors.brand, size: 18),
- ],
- ),
- ),
- ))
- .toList(),
- ),
- ),
- );
- }
- }
- // ── 保证金模式图标 ─────────────────────────────────────────────
- enum _MarginModeIcon { cross, split }
- class _MarginModeIconWidget extends StatelessWidget {
- const _MarginModeIconWidget({required this.type});
- final _MarginModeIcon type;
- @override
- Widget build(BuildContext context) {
- final asset = switch (type) {
- _MarginModeIcon.cross => 'assets/images/eg_ticket_dark.png',
- _MarginModeIcon.split => 'assets/images/eg_split.png',
- };
- return Image.asset(asset, width: 52, height: 52, fit: BoxFit.contain);
- }
- }
- class _LargeInput extends StatefulWidget {
- const _LargeInput({
- super.key,
- required this.controller,
- required this.label,
- required this.unit,
- this.suffixDropdown,
- this.showUnitDropdown = false,
- this.onUnitTap,
- this.inputFormatters,
- });
- final TextEditingController controller;
- final String label;
- final String unit;
- final String? suffixDropdown;
- final bool showUnitDropdown;
- final VoidCallback? onUnitTap;
- final List<TextInputFormatter>? inputFormatters;
- @override
- State<_LargeInput> createState() => _LargeInputState();
- }
- class _LargeInputState extends State<_LargeInput>
- with SingleTickerProviderStateMixin {
- final _focusNode = FocusNode();
- late final AnimationController _animCtrl;
- late final Animation<double> _curvedAnim;
- // 是否处于激活态(聚焦 或 有内容)
- bool get _isActive =>
- _focusNode.hasFocus || widget.controller.text.isNotEmpty;
- @override
- void initState() {
- super.initState();
- _animCtrl = AnimationController(
- vsync: this,
- duration: const Duration(milliseconds: 220),
- );
- _curvedAnim = CurvedAnimation(
- parent: _animCtrl,
- curve: const Cubic(0.4, 0.0, 0.2, 1.0), // 与原型一致
- );
- _focusNode.addListener(_onChanged);
- widget.controller.addListener(_onChanged);
- // 若初始时已有内容,跳过动画直接到激活态
- if (_isActive) _animCtrl.value = 1.0;
- }
- @override
- void didUpdateWidget(_LargeInput oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.controller != widget.controller) {
- // controller 实例变化时:移除旧监听、绑定新监听,并同步动画状态
- oldWidget.controller.removeListener(_onChanged);
- widget.controller.addListener(_onChanged);
- if (_isActive) {
- _animCtrl.value = 1.0;
- } else {
- _animCtrl.value = 0.0;
- }
- }
- }
- void _onChanged() {
- if (_isActive) {
- _animCtrl.forward();
- } else {
- _animCtrl.reverse();
- }
- setState(() {});
- }
- @override
- void dispose() {
- _focusNode.removeListener(_onChanged);
- widget.controller.removeListener(_onChanged);
- _focusNode.dispose();
- _animCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final unfocusedBg =
- isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
- final focusedBg = isDark ? AppColors.darkBgSecondary : Colors.white;
- final activeBorderColor = isDark
- ? AppColors.darkTextPrimary.withAlpha(200)
- : const Color(0xFF383838);
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: () => _focusNode.requestFocus(),
- child: Container(
- height: 44,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(
- color: _focusNode.hasFocus ? focusedBg : unfocusedBg,
- borderRadius: BorderRadius.circular(8),
- border: _focusNode.hasFocus
- ? Border.all(color: activeBorderColor, width: 1.5)
- : null,
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Expanded(
- child: AnimatedBuilder(
- animation: _curvedAnim,
- builder: (context, inputChild) {
- final t = _curvedAnim.value;
- final labelSize = 13.0 + (10.0 - 13.0) * t;
- final labelColor = isDark
- ? AppColors.darkTextSecondary
- : AppColors.lightTextSecondary;
- final labelHeight = labelSize * 1.0;
- final centerTop = (44.0 - labelHeight) / 2;
- const activeTop = 5.0;
- final labelTop = centerTop + (activeTop - centerTop) * t;
- final inputOpacity = t;
- return SizedBox(
- height: 44,
- child: Stack(
- clipBehavior: Clip.none,
- children: [
- Positioned(
- top: labelTop,
- left: 0,
- right: 0,
- child: Text(
- widget.label,
- style: TextStyle(
- color: labelColor,
- fontSize: labelSize,
- height: 1.0,
- ),
- ),
- ),
- Positioned(
- bottom: 5,
- left: 0,
- right: 0,
- child: Opacity(
- opacity: inputOpacity,
- child: inputChild,
- ),
- ),
- ],
- ),
- );
- },
- child: TextField(
- focusNode: _focusNode,
- controller: widget.controller,
- keyboardType:
- const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: widget.inputFormatters,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500,
- ),
- decoration: const InputDecoration(
- border: InputBorder.none,
- focusedBorder: InputBorder.none,
- enabledBorder: InputBorder.none,
- filled: false,
- isDense: true,
- contentPadding: EdgeInsets.zero,
- ),
- ),
- ),
- ),
- const SizedBox(width: 6),
- GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: widget.showUnitDropdown
- ? () {
- _focusNode.unfocus();
- widget.onUnitTap?.call();
- }
- : null,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(widget.unit,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 13)),
- if (widget.showUnitDropdown)
- Icon(Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153), size: 14),
- ],
- ),
- ),
- if (widget.suffixDropdown != null) ...[
- const SizedBox(width: 6),
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(widget.suffixDropdown!,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12)),
- Icon(Icons.keyboard_arrow_down,
- color: cs.onSurface.withAlpha(153), size: 14),
- ],
- ),
- ],
- ],
- ),
- ),
- );
- }
- }
- class _PercentSlider extends StatefulWidget {
- const _PercentSlider({
- required this.percent,
- required this.onChanged,
- this.showQuickButtons = true,
- this.enabled = true,
- });
- final double percent;
- final ValueChanged<double> onChanged;
- final bool showQuickButtons;
- /// false 时忽略所有手势,仅展示当前进度
- final bool enabled;
- @override
- State<_PercentSlider> createState() => _PercentSliderState();
- }
- class _PercentSliderState extends State<_PercentSlider> {
- // 行业标准快捷百分比(25/50/75/100,与主流交易所一致)
- static const _quickPcts = [0.0, 0.25, 0.50, 0.75, 1.00];
- static const _tickPcts = [0.0, 0.25, 0.50, 0.75, 1.00];
- static const _thumbSize = 18.0;
- static const _trackH = _thumbSize;
- bool _isDragging = false;
- double _lastHapticPct = -1;
- // OverlayPortal:气泡渲染到 Overlay 层,不受任何祖先裁剪影响
- final _overlayCtrl = OverlayPortalController();
- // CompositedTransformTarget key,用于获取滑块在屏幕中的绝对位置
- final _layerLink = LayerLink();
- double _trackWidth = 0;
- void _onDrag(double dx, double w) {
- // 有效轨道范围:两端各留半个滑块
- const r = _thumbSize / 2;
- final trackW = w - _thumbSize;
- final newPct = ((dx - r) / trackW).clamp(0.0, 1.0);
- final lastStep = (_lastHapticPct * 100).floor();
- final newStep = (newPct * 100).floor();
- if (newStep != lastStep) HapticFeedback.selectionClick();
- _lastHapticPct = newPct;
- widget.onChanged(newPct);
- }
- void _startDrag(double dx, double w) {
- _trackWidth = w;
- setState(() => _isDragging = true);
- _overlayCtrl.show();
- _onDrag(dx, w);
- }
- void _endDrag() {
- setState(() => _isDragging = false);
- _overlayCtrl.hide();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final percent = widget.percent;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // ── 滑轨区 ───────────────────────────────────────────
- SizedBox(
- height: _trackH,
- child: LayoutBuilder(
- builder: (_, box) {
- final w = box.maxWidth;
- // 轨道有效宽度:两端各留半个滑块,使 0%/100% 时滑块完整显示
- const r = _thumbSize / 2;
- final trackW = w - _thumbSize;
- final thumbX = r + trackW * percent;
- return CompositedTransformTarget(
- link: _layerLink,
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onHorizontalDragStart: widget.enabled
- ? (d) => _startDrag(d.localPosition.dx, w)
- : null,
- onHorizontalDragUpdate: widget.enabled
- ? (d) => _onDrag(d.localPosition.dx, w)
- : null,
- onHorizontalDragEnd:
- widget.enabled ? (_) => _endDrag() : null,
- onTapDown: widget.enabled
- ? (d) {
- // 触点转换为有效轨道范围内的百分比
- final pct = ((d.localPosition.dx - r) / trackW)
- .clamp(0.0, 1.0);
- HapticFeedback.selectionClick();
- _lastHapticPct = pct;
- widget.onChanged(pct);
- }
- : null,
- // OverlayPortal:气泡挂载到 Overlay,脱离布局树
- child: OverlayPortal(
- controller: _overlayCtrl,
- overlayChildBuilder: (_) {
- final tw = _trackWidth > 0 ? _trackWidth : w;
- // 滑块圆心的 X:与 thumbX 计算方式一致
- const bubbleR = _thumbSize / 2;
- final effW = tw - _thumbSize;
- final thumbCx = bubbleR + effW * percent;
- // 气泡宽40,对齐滑块中心,边界夹紧
- final bubbleOffsetX =
- (thumbCx - 20).clamp(0.0, tw - 40.0);
- // 气泡高30(24容器+6箭头),置于滑块上方 4px
- const bubbleH = 30.0;
- return Positioned(
- left: 0,
- top: 0,
- child: CompositedTransformFollower(
- link: _layerLink,
- showWhenUnlinked: false,
- // 从 target 左上角偏移:x=气泡位置,y=轨道上方
- offset: Offset(bubbleOffsetX, -(bubbleH + 4)),
- child: _PercentBubble(percent: percent),
- ),
- );
- },
- child: Stack(
- children: [
- // 背景轨道(两端与有效轨道对齐,即 r ~ w-r)
- Positioned(
- top: (_trackH - 3) / 2,
- left: r,
- right: r,
- height: 3,
- child: Container(
- decoration: BoxDecoration(
- color: cs.outline.withAlpha(50),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- ),
- // 已填充轨道(从 r 开始到滑块圆心)
- Positioned(
- top: (_trackH - 3) / 2,
- left: r,
- width: thumbX - r,
- height: 3,
- child: Container(
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- ),
- // 刻度点(在有效轨道范围内按百分比定位)
- for (final p in _tickPcts)
- Positioned(
- left: r + trackW * p - 3,
- top: (_trackH - 6) / 2,
- child: Container(
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: p <= percent
- ? AppColors.brand
- : cs.outline.withAlpha(80),
- ),
- ),
- ),
- // 滑块把手(圆心在 thumbX)
- Positioned(
- left: thumbX - r,
- top: 0,
- width: _thumbSize,
- height: _thumbSize,
- child: Container(
- decoration: BoxDecoration(
- color: Colors.white,
- shape: BoxShape.circle,
- border: Border.all(
- color: AppColors.brand, width: 2.5),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withAlpha(70),
- blurRadius: 5,
- spreadRadius: 1,
- offset: const Offset(0, 1.5),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- );
- },
- ),
- ),
- if (widget.showQuickButtons) ...[
- const SizedBox(height: 4),
- // ── 快捷按钮 0/25/50/75/100:Stack+Positioned,按钮中心与刻度对齐 ──
- LayoutBuilder(builder: (_, box) {
- final w = box.maxWidth;
- const r = _thumbSize / 2;
- final trackW = w - _thumbSize;
- // 相邻刻度间距,减去固定间隙 6px,保证各机型按钮间隔一致
- final tickInterval = trackW / (_quickPcts.length - 1);
- final btnW = (tickInterval - 6).clamp(20.0, 64.0);
- return SizedBox(
- height: 22,
- child: Stack(
- children: [
- for (final pct in _quickPcts)
- Positioned(
- // 按钮中心对齐刻度圆心,边界夹紧防止溢出
- left: (r + trackW * pct - btnW / 2).clamp(0.0, w - btnW),
- top: 0,
- width: btnW,
- height: 22,
- child: Builder(builder: (_) {
- final isSelected = (percent - pct).abs() < 0.001;
- return GestureDetector(
- onTap: widget.enabled
- ? () {
- HapticFeedback.selectionClick();
- _lastHapticPct = pct;
- widget.onChanged(pct);
- }
- : null,
- child: Container(
- decoration: BoxDecoration(
- color: isSelected
- ? AppColors.brand
- : cs.outline.withAlpha(25),
- borderRadius: BorderRadius.circular(4),
- ),
- alignment: Alignment.center,
- child: Text(
- '${(pct * 100).toInt()}%',
- style: TextStyle(
- color: isSelected
- ? Colors.black
- : cs.onSurface.withAlpha(102),
- fontSize: 10,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- );
- }),
- ),
- ],
- ),
- );
- }),
- ],
- ],
- );
- }
- }
- /// 拖动时显示在滑块上方的百分比气泡(气泡 + 向下三角箭头)
- class _PercentBubble extends StatelessWidget {
- const _PercentBubble({required this.percent});
- final double percent;
- @override
- Widget build(BuildContext context) {
- final label = '${(percent * 100).round()}%';
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 40,
- height: 24,
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- label,
- style: const TextStyle(
- color: Colors.black,
- fontSize: 11,
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- CustomPaint(
- size: const Size(8, 6),
- painter: _BubbleArrowPainter(),
- ),
- ],
- );
- }
- }
- class _BubbleArrowPainter extends CustomPainter {
- @override
- void paint(Canvas canvas, Size size) {
- final paint = Paint()..color = AppColors.brand;
- final path = Path()
- ..moveTo(0, 0)
- ..lineTo(size.width / 2, size.height)
- ..lineTo(size.width, 0)
- ..close();
- canvas.drawPath(path, paint);
- }
- @override
- bool shouldRepaint(_BubbleArrowPainter _) => false;
- }
- class _TpslSection extends StatelessWidget {
- const _TpslSection({
- required this.enabled,
- required this.onToggle,
- required this.tpController,
- required this.slController,
- });
- final bool enabled;
- final VoidCallback onToggle;
- final TextEditingController tpController;
- final TextEditingController slController;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Column(
- children: [
- GestureDetector(
- onTap: onToggle,
- child: Row(
- children: [
- Container(
- width: 16,
- height: 16,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(4),
- border: Border.all(
- color:
- enabled ? AppColors.brand : cs.onSurface.withAlpha(153),
- width: 1.5,
- ),
- color: enabled ? AppColors.brand : Colors.transparent,
- ),
- child: enabled
- ? const Icon(Icons.check, size: 12, color: Colors.black)
- : null,
- ),
- const SizedBox(width: 6),
- Text(AppLocalizations.of(context)!.takeProfitStopLoss,
- style: TextStyle(color: cs.onSurface, fontSize: 13)),
- ],
- ),
- ),
- if (enabled) ...[
- const SizedBox(height: 6),
- _SmallInput(
- controller: tpController,
- hint: '${AppLocalizations.of(context)!.takeProfitPrice} (USDT)',
- ),
- const SizedBox(height: 4),
- _SmallInput(
- controller: slController,
- hint: '${AppLocalizations.of(context)!.stopLossPrice} (USDT)',
- ),
- ],
- ],
- );
- }
- }
- class _SmallInput extends StatefulWidget {
- const _SmallInput({required this.controller, required this.hint});
- final TextEditingController controller;
- final String hint;
- @override
- State<_SmallInput> createState() => _SmallInputState();
- }
- class _SmallInputState extends State<_SmallInput> {
- final _focusNode = FocusNode();
- @override
- void initState() {
- super.initState();
- _focusNode.addListener(() {
- if (mounted) setState(() {});
- });
- }
- @override
- void dispose() {
- _focusNode.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final isFocused = _focusNode.hasFocus;
- final bgColor = isFocused
- ? (isDark ? AppColors.darkBgSecondary : Colors.white)
- : (isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary);
- final activeBorder = isDark
- ? AppColors.darkTextPrimary.withAlpha(200)
- : const Color(0xFF383838);
- return Container(
- height: 34,
- padding: const EdgeInsets.symmetric(horizontal: 10),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(6),
- border: isFocused ? Border.all(color: activeBorder, width: 1.5) : null,
- ),
- child: TextField(
- controller: widget.controller,
- focusNode: _focusNode,
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
- style: TextStyle(color: cs.onSurface, fontSize: 12),
- decoration: InputDecoration(
- hintText: widget.hint,
- hintStyle:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
- border: InputBorder.none,
- focusedBorder: InputBorder.none,
- enabledBorder: InputBorder.none,
- filled: false,
- isDense: true,
- contentPadding:
- const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
- ),
- ),
- );
- }
- }
- // 资金费率行 — 只订阅 fundingRate + fundingCountdown,每秒更新一次,不触发盘口重建
- class _FundingRateRow extends ConsumerWidget {
- const _FundingRateRow({required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(symbol);
- final fundingRate = ref.watch(provider.select((s) => s.fundingRate));
- final fundingCountdown =
- ref.watch(provider.select((s) => s.fundingCountdown));
- return Row(
- mainAxisAlignment: MainAxisAlignment.end,
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(AppLocalizations.of(context)!.fundingRateCountdown,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- Text(
- '${fundingRate >= 0 ? '+' : ''}${(fundingRate * 100).toStringAsFixed(4)}% / $fundingCountdown',
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontFeatures: const [FontFeature.tabularFigures()]),
- ),
- ],
- ),
- ],
- );
- }
- }
- class _OrderBookPanel extends ConsumerStatefulWidget {
- const _OrderBookPanel({
- required this.symbol,
- required this.rowCount,
- this.rowHeight = 22.0,
- this.onPriceTap,
- });
- final String symbol;
- final int rowCount;
- final double rowHeight;
- final ValueChanged<double>? onPriceTap;
- @override
- ConsumerState<_OrderBookPanel> createState() => _OrderBookPanelState();
- }
- /// 预计算后的单行盘口数据,避免 build() 内重复调用格式化函数
- class _BookRowData {
- const _BookRowData({
- required this.price,
- required this.qty,
- required this.formattedPrice,
- required this.depthPercent,
- });
- final double price;
- final double qty;
- final String formattedPrice;
- final double depthPercent;
- }
- class _OrderBookPanelState extends ConsumerState<_OrderBookPanel> {
- // 订单薄显示模式: 0=双向, 1=仅卖盘, 2=仅买盘
- int _bookMode = 0;
- // 缓存:只在 rawAsks/rawBids/bookMode 变化时重新计算
- List<Map<String, dynamic>>? _prevAsks;
- List<Map<String, dynamic>>? _prevBids;
- int _prevBookMode = -1;
- int _prevRowCount = -1;
- int _prevPricePrecision = -1;
- List<_BookRowData> _askRows = const [];
- List<_BookRowData> _bidRows = const [];
- double _bidRatio = 0.5;
- void _recompute(
- List<Map<String, dynamic>> rawAsks,
- List<Map<String, dynamic>> rawBids,
- double minTick,
- int decimalDigits,
- ) {
- _prevAsks = rawAsks;
- _prevBids = rawBids;
- _prevBookMode = _bookMode;
- _prevRowCount = widget.rowCount;
- _prevPricePrecision = decimalDigits;
- final aggAsks = _bookMode == 2 ? <Map<String, dynamic>>[] : rawAsks;
- final aggBids = _bookMode == 1 ? <Map<String, dynamic>>[] : rawBids;
- final askRows = _bookMode == 2
- ? 0
- : (_bookMode == 1 ? widget.rowCount * 2 : widget.rowCount);
- final bidRows = _bookMode == 1
- ? 0
- : (_bookMode == 2 ? widget.rowCount * 2 : widget.rowCount);
- final askSlice = aggAsks.take(askRows).toList();
- final bidSlice = aggBids.take(bidRows).toList();
- double totalAsk = 0, totalBid = 0, maxQ = 0.001;
- for (var e in askSlice) {
- final q = _toDouble(e['quantity']);
- totalAsk += q;
- if (q > maxQ) maxQ = q;
- }
- for (var e in bidSlice) {
- final q = _toDouble(e['quantity']);
- totalBid += q;
- if (q > maxQ) maxQ = q;
- }
- final total = totalAsk + totalBid;
- _bidRatio = total > 0 ? totalBid / total : 0.5;
- // 卖盘:slice 升序,UI 反转(最高价在顶)
- _askRows = List.generate(askRows, (i) {
- final ri = askSlice.length - 1 - i;
- if (ri < 0)
- return const _BookRowData(
- price: 0, qty: 0, formattedPrice: '', depthPercent: 0);
- final price = _toDouble(askSlice[ri]['price']);
- final qty = _toDouble(askSlice[ri]['quantity']);
- return _BookRowData(
- price: price,
- qty: qty,
- formattedPrice: _fmtPrice(price, minTick, decimalDigits),
- depthPercent: qty / maxQ,
- );
- });
- _bidRows = List.generate(bidRows, (i) {
- if (i >= bidSlice.length)
- return const _BookRowData(
- price: 0, qty: 0, formattedPrice: '', depthPercent: 0);
- final price = _toDouble(bidSlice[i]['price']);
- final qty = _toDouble(bidSlice[i]['quantity']);
- return _BookRowData(
- price: price,
- qty: qty,
- formattedPrice: _fmtPrice(price, minTick, decimalDigits),
- depthPercent: qty / maxQ,
- );
- });
- }
- // 根据合约价格精度计算小数位数
- int _decimalDigits(int pricePrecision) {
- if (pricePrecision <= 0) return 0;
- return pricePrecision;
- }
- // 将价格按精度对齐(始终使用合约最小精度档)
- double _roundToPrecision(double price, double minTick) {
- return (price / minTick).round() * minTick;
- }
- String _fmtPrice(double price, double minTick, int decimalDigits) {
- final str = price.toString();
- // 如果原始字符串中有小数点,直接返回;否则返回固定格式
- return str.contains('.') ? str : price.toStringAsFixed(0);
- }
- // 将原始订单列表按精度合并:相同价格档位的数量求和
- // isSell=true → 按价格升序(卖盘,低价在前)
- // isSell=false → 按价格降序(买盘,高价在前)
- List<Map<String, dynamic>> _aggregate(
- List<Map<String, dynamic>> raw, bool isSell, double minTick) {
- final Map<double, double> bucket = {};
- for (final o in raw) {
- final p = _toDouble(o['price']);
- final q = _toDouble(o['quantity']);
- if (p <= 0 || q <= 0) continue;
- final key = _roundToPrecision(p, minTick);
- bucket[key] = (bucket[key] ?? 0) + q;
- }
- final entries = bucket.entries.toList()
- ..sort((a, b) => isSell
- ? a.key.compareTo(b.key) // 卖盘升序
- : b.key.compareTo(a.key)); // 买盘降序
- return entries.map((e) => {'price': e.key, 'quantity': e.value}).toList();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final provider = futuresProvider(widget.symbol);
- final lastPrice = ref.watch(provider.select((s) => s.lastPrice));
- final lastPriceStr = ref.watch(provider.select((s) => s.lastPriceStr));
- final rawAsks = ref.watch(provider.select((s) => s.orderBookAsks));
- final rawBids = ref.watch(provider.select((s) => s.orderBookBids));
- final pricePrecision = ref.watch(provider.select((s) => s.pricePrecision));
- final decimalDigits = _decimalDigits(pricePrecision);
- final minTick = pricePrecision >= 0
- ? pow(10, pricePrecision).toDouble()
- : 1.0 / pow(10, -pricePrecision).toDouble();
- // 只在数据或模式变化时重新计算行数据(避免每帧重复 O(n) 工作)
- if (!identical(rawAsks, _prevAsks) ||
- !identical(rawBids, _prevBids) ||
- _bookMode != _prevBookMode ||
- widget.rowCount != _prevRowCount ||
- decimalDigits != _prevPricePrecision) {
- _recompute(rawAsks, rawBids, minTick, decimalDigits);
- }
- final askRatio = 1.0 - _bidRatio;
- final labelStyle =
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11);
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.max,
- children: [
- // 资金费率独立 widget,每秒重建,不影响盘口行
- RepaintBoundary(child: _FundingRateRow(symbol: widget.symbol)),
- const SizedBox(height: 2),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Flexible(
- child: Text(AppLocalizations.of(context)!.priceUsdt,
- overflow: TextOverflow.ellipsis, style: labelStyle)),
- const SizedBox(width: 4),
- Flexible(
- child: Text(
- AppLocalizations.of(context)!
- .amountLabel2(_baseCoin(widget.symbol)),
- overflow: TextOverflow.ellipsis,
- style: labelStyle)),
- ],
- ),
- const SizedBox(height: 4),
- // 卖盘
- if (_bookMode != 2)
- for (var i = 0; i < _askRows.length; i++)
- _askRows[i].price > 0
- ? RepaintBoundary(
- key: ValueKey('ask_$i'),
- child: _BookRow(
- price: _askRows[i].price,
- isSell: true,
- amount: _askRows[i].qty.toStringAsFixed(4),
- depthPercent: _askRows[i].depthPercent,
- formattedPrice: _askRows[i].formattedPrice,
- rowHeight: widget.rowHeight,
- onTap: widget.onPriceTap != null
- ? () => widget.onPriceTap!(_askRows[i].price)
- : null,
- ),
- )
- : _BookRowPlaceholder(
- key: ValueKey('ask_ph_$i'), rowHeight: widget.rowHeight),
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 5),
- child: FittedBox(
- fit: BoxFit.scaleDown,
- alignment: Alignment.centerLeft,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(
- lastPriceStr != null
- ? formatRawPrice(lastPriceStr)
- : formatPrice(lastPrice),
- maxLines: 1,
- overflow: TextOverflow.clip,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w700,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- const SizedBox(width: 4),
- Text(
- '≈\$${formatPrice(lastPrice * 0.9999)}',
- maxLines: 1,
- overflow: TextOverflow.clip,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 10,
- fontFeatures: const [FontFeature.tabularFigures()],
- ),
- ),
- ],
- ),
- ),
- ),
- // 买盘
- if (_bookMode != 1)
- for (var i = 0; i < _bidRows.length; i++)
- _bidRows[i].price > 0
- ? RepaintBoundary(
- key: ValueKey('bid_$i'),
- child: _BookRow(
- price: _bidRows[i].price,
- isSell: false,
- amount: _bidRows[i].qty.toStringAsFixed(4),
- depthPercent: _bidRows[i].depthPercent,
- formattedPrice: _bidRows[i].formattedPrice,
- rowHeight: widget.rowHeight,
- onTap: widget.onPriceTap != null
- ? () => widget.onPriceTap!(_bidRows[i].price)
- : null,
- ),
- )
- : _BookRowPlaceholder(
- key: ValueKey('bid_ph_$i'), rowHeight: widget.rowHeight),
- const Spacer(), // 撑开剩余空间,让底部元素贴底
- ClipRRect(
- borderRadius: BorderRadius.circular(2),
- child: Row(
- children: [
- Flexible(
- flex: (_bidRatio * 100).round().clamp(1, 99),
- child: Container(
- height: 4, color: AppColors.rise.withAlpha(180)),
- ),
- Flexible(
- flex: (askRatio * 100).round().clamp(1, 99),
- child: Container(
- height: 4, color: AppColors.fall.withAlpha(180)),
- ),
- ],
- ),
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text('${(_bidRatio * 100).toStringAsFixed(2)}%',
- style: const TextStyle(color: AppColors.rise, fontSize: 10)),
- Text('${(askRatio * 100).toStringAsFixed(2)}%',
- style: const TextStyle(color: AppColors.fall, fontSize: 10)),
- ],
- ),
- const SizedBox(height: 2),
- Row(
- children: [
- const Spacer(),
- // 订单薄模式切换
- GestureDetector(
- onTap: () => setState(() => _bookMode = (_bookMode + 1) % 3),
- child: Container(
- width: 28,
- height: 20,
- padding:
- const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
- decoration: BoxDecoration(
- border: Border.all(color: cs.outline.withAlpha(80)),
- borderRadius: BorderRadius.circular(4),
- ),
- child: _BookModeIcon(mode: _bookMode),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- class _BookRow extends StatelessWidget {
- const _BookRow({
- super.key,
- required this.price,
- required this.isSell,
- required this.amount,
- this.depthPercent = 0.3,
- this.formattedPrice,
- this.rowHeight = 22.0,
- this.onTap,
- });
- final double price;
- final bool isSell;
- final String amount;
- final double depthPercent;
- final String? formattedPrice;
- final double rowHeight;
- final VoidCallback? onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final color = isSell ? AppColors.fall : AppColors.rise;
- return GestureDetector(
- onTap: onTap,
- behavior: HitTestBehavior.opaque,
- child: SizedBox(
- height: rowHeight,
- child: LayoutBuilder(
- builder: (_, constraints) {
- final barW = constraints.maxWidth * depthPercent.clamp(0.0, 1.0);
- return Stack(
- children: [
- Positioned(
- right: 0,
- top: 1,
- bottom: 1,
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 300),
- curve: Curves.easeOut,
- width: barW,
- decoration: BoxDecoration(
- color: color.withAlpha(38),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 1),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(formattedPrice ?? formatPrice(price),
- style: TextStyle(
- color: color,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- fontFeatures: const [
- FontFeature.tabularFigures()
- ])),
- Text(amount,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontFeatures: const [
- FontFeature.tabularFigures()
- ])),
- ],
- ),
- ),
- ],
- );
- },
- ),
- ),
- );
- }
- }
- /// 订单薄模式图标:3种视觉样式
- /// mode=0 双向 | mode=1 仅卖盘 | mode=2 仅买盘
- class _BookModeIcon extends StatelessWidget {
- const _BookModeIcon({required this.mode});
- final int mode;
- @override
- Widget build(BuildContext context) {
- const sellColor = AppColors.fall;
- const buyColor = AppColors.rise;
- const emptyColor = Color(0xFFCCCCCC);
- final double lineH = 2.0;
- final double gap = 1.5;
- Widget line(Color color) => Container(
- height: lineH,
- decoration: BoxDecoration(
- color: color,
- borderRadius: BorderRadius.circular(1),
- ),
- );
- // 3 lines: top=sell side, bottom=buy side
- // mode0: top 1.5 sell lines + 1.5 buy lines
- // mode1: all sell (red)
- // mode2: all buy (green)
- final List<Widget> lines;
- if (mode == 0) {
- lines = [
- line(sellColor),
- SizedBox(height: gap),
- line(sellColor),
- SizedBox(height: gap),
- line(buyColor),
- SizedBox(height: gap),
- line(buyColor),
- ];
- } else if (mode == 1) {
- lines = [
- line(sellColor),
- SizedBox(height: gap),
- line(sellColor),
- SizedBox(height: gap),
- line(sellColor),
- SizedBox(height: gap),
- line(emptyColor),
- ];
- } else {
- lines = [
- line(emptyColor),
- SizedBox(height: gap),
- line(buyColor),
- SizedBox(height: gap),
- line(buyColor),
- SizedBox(height: gap),
- line(buyColor),
- ];
- }
- return Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: lines,
- );
- }
- }
- /// 无数据时的占位行,高度与 _BookRow 保持一致
- class _BookRowPlaceholder extends StatelessWidget {
- const _BookRowPlaceholder({super.key, this.rowHeight = 22.0});
- final double rowHeight;
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: SizedBox(
- height: rowHeight,
- child: Row(
- children: [
- Expanded(
- child: Align(
- alignment: Alignment.centerLeft,
- child: shimmerBox(60, 10),
- )),
- Expanded(
- child: Align(
- alignment: Alignment.centerRight,
- child: shimmerBox(50, 10),
- )),
- ],
- ),
- ),
- );
- }
- }
- // ── 自适应高度辅助 ─────────────────────────────────────────
- // 用 OverflowBox 解除父级高度约束,让子内容按自然高度布局,
- // 再通过 addPostFrameCallback 读取真实 RenderBox 高度上报给父级。
- // PageView 本身会 clip,所以 OverflowBox 超出部分不会显示。
- class _MeasureSize extends StatefulWidget {
- const _MeasureSize({required this.child, required this.onSize});
- final Widget child;
- final ValueChanged<double> onSize;
- @override
- State<_MeasureSize> createState() => _MeasureSizeState();
- }
- class _MeasureSizeState extends State<_MeasureSize> {
- final _key = GlobalKey();
- double? _last;
- @override
- Widget build(BuildContext context) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- final box = _key.currentContext?.findRenderObject() as RenderBox?;
- if (box == null || !box.hasSize) return;
- final h = box.size.height;
- if (h != _last) {
- _last = h;
- widget.onSize(h);
- }
- });
- // OverflowBox:宽度跟父级一致,高度不设上限,让子内容自由伸展。
- // 这样 RenderBox.size.height 反映的是内容的真实高度,而不是父级约束高度。
- return OverflowBox(
- alignment: Alignment.topLeft,
- minHeight: 0,
- maxHeight: double.infinity,
- child: KeyedSubtree(key: _key, child: widget.child),
- );
- }
- }
- class _BottomSection extends ConsumerStatefulWidget {
- const _BottomSection({required this.symbol});
- final String symbol;
- @override
- ConsumerState<_BottomSection> createState() => _BottomSectionState();
- }
- class _BottomSectionState extends ConsumerState<_BottomSection> {
- late PageController _pageController;
- bool _programmaticSwitch = false;
- // 三个 Tab 各自测量到的高度。
- // 初始值需能容纳 shimmer 占位内容(positions rows=2 ≈252px,orders rows=3 ≈380px,
- // assets ≈260px),避免第一帧溢出。OverflowBox 测量到真实高度后会立即更新。
- final _tabHeights = [280.0, 420.0, 280.0];
- static int _tabToIndex(FuturesTab tab) {
- switch (tab) {
- case FuturesTab.positions:
- return 0;
- case FuturesTab.orders:
- return 1;
- case FuturesTab.assets:
- return 2;
- }
- }
- static FuturesTab _indexToTab(int index) {
- switch (index) {
- case 0:
- return FuturesTab.positions;
- case 1:
- return FuturesTab.orders;
- default:
- return FuturesTab.assets;
- }
- }
- @override
- void initState() {
- super.initState();
- final initial = ref.read(futuresProvider(widget.symbol)).activeTab;
- _pageController = PageController(initialPage: _tabToIndex(initial));
- }
- @override
- void dispose() {
- _pageController.dispose();
- super.dispose();
- }
- void _onTabTap(FuturesTab tab) {
- _programmaticSwitch = true;
- ref.read(futuresProvider(widget.symbol).notifier).setTab(tab);
- _pageController.animateToPage(
- _tabToIndex(tab),
- duration: const Duration(milliseconds: 280),
- curve: Curves.easeOut,
- );
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final symbol = widget.symbol;
- final provider = futuresProvider(symbol);
- final activeTab = ref.watch(provider.select((s) => s.activeTab));
- final positionsCount =
- ref.watch(provider.select((s) => s.positions.length));
- final ordersCount = ref.watch(provider.select((s) => s.openOrders.length));
- final isTabLoading = ref.watch(provider.select((s) => s.isTabLoading));
- return Container(
- decoration: BoxDecoration(
- border: Border(top: BorderSide(color: cs.outline)),
- ),
- child: Column(
- children: [
- // Tab 头
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: Row(
- children: [
- _BottomTab(
- label: AppLocalizations.of(context)!.positionsTab,
- count: positionsCount > 0 ? '($positionsCount)' : null,
- countColor: AppColors.fall,
- active: activeTab == FuturesTab.positions,
- onTap: () => _onTabTap(FuturesTab.positions),
- ),
- const SizedBox(width: 16),
- _BottomTab(
- label: AppLocalizations.of(context)!.currentOrders,
- count: ordersCount > 0 ? '($ordersCount)' : null,
- active: activeTab == FuturesTab.orders,
- onTap: () => _onTabTap(FuturesTab.orders),
- ),
- const SizedBox(width: 16),
- _BottomTab(
- label: AppLocalizations.of(context)!.assets,
- active: activeTab == FuturesTab.assets,
- onTap: () => _onTabTap(FuturesTab.assets),
- ),
- const Spacer(),
- GestureDetector(
- onTap: () async {
- final notifier = ref.read(futuresProvider(symbol).notifier);
- notifier.stopPolling();
- await context.push('/futures/$symbol/history');
- if (context.mounted) notifier.resumePolling(symbol);
- },
- child: Icon(Icons.access_time,
- color: cs.onSurface.withAlpha(153), size: 18),
- ),
- ],
- ),
- ),
- // 工具栏(持仓/委托各有操作按钮)
- AnimatedSwitcher(
- duration: const Duration(milliseconds: 200),
- child: activeTab == FuturesTab.positions
- ? _PositionToolbar(
- key: const ValueKey('pos_toolbar'), symbol: symbol)
- : activeTab == FuturesTab.orders
- ? _OrderToolbar(
- key: const ValueKey('ord_toolbar'), symbol: symbol)
- : const SizedBox.shrink(key: ValueKey('no_toolbar')),
- ),
- // 滑动内容区
- // PageView 在 Column 内必须有有界高度。
- // 通过 _MeasureSize 让每个 Tab 上报自身真实高度,取三者最大值,
- // 避免硬编码估算值在内容变化时溢出。
- SizedBox(
- height: _tabHeights.reduce(max).clamp(100.0, 2000.0),
- child: RepaintBoundary(
- child: PageView(
- controller: _pageController,
- physics: const ClampingScrollPhysics(),
- onPageChanged: (index) {
- if (_programmaticSwitch) {
- _programmaticSwitch = false;
- return;
- }
- final tab = _indexToTab(index);
- ref.read(futuresProvider(symbol).notifier).setTab(tab);
- },
- children: [
- _MeasureSize(
- onSize: (h) {
- if (_tabHeights[0] != h)
- setState(() => _tabHeights[0] = h);
- },
- child: _BottomTabContent(
- symbol: symbol, tab: FuturesTab.positions),
- ),
- _MeasureSize(
- onSize: (h) {
- if (_tabHeights[1] != h)
- setState(() => _tabHeights[1] = h);
- },
- child: _BottomTabContent(
- symbol: symbol, tab: FuturesTab.orders),
- ),
- _MeasureSize(
- onSize: (h) {
- if (_tabHeights[2] != h)
- setState(() => _tabHeights[2] = h);
- },
- child: _BottomTabContent(
- symbol: symbol, tab: FuturesTab.assets),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
- class _BottomTabContent extends ConsumerWidget {
- const _BottomTabContent({required this.symbol, required this.tab});
- final String symbol;
- final FuturesTab tab;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final provider = futuresProvider(symbol);
- final isTabLoading = ref.watch(provider.select((s) => s.isTabLoading));
- switch (tab) {
- case FuturesTab.positions:
- final positions = ref.watch(provider.select((s) => s.displayPositions));
- if (isTabLoading && positions.isEmpty)
- return const _TabShimmer(rows: 2);
- return _PositionsList(symbol: symbol, positions: positions);
- case FuturesTab.orders:
- final openOrders = ref.watch(provider.select((s) => s.displayOrders));
- if (isTabLoading && openOrders.isEmpty)
- return const _TabShimmer(rows: 3);
- return _OrdersList(symbol: symbol, orders: openOrders);
- case FuturesTab.assets:
- final accountInfo = ref.watch(provider.select((s) => s.accountInfo));
- if (isTabLoading && accountInfo.totalBalance == 0)
- return const _AssetShimmer();
- return _AssetsPanel(info: accountInfo);
- }
- }
- }
- class _PositionToolbar extends ConsumerWidget {
- const _PositionToolbar({super.key, required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final notifier = ref.read(futuresProvider(symbol).notifier);
- final hideOther =
- ref.watch(futuresProvider(symbol).select((s) => s.hideOtherSymbols));
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- child: Row(
- children: [
- GestureDetector(
- onTap: notifier.toggleHideOtherSymbols,
- child: Row(
- children: [
- _CheckBox(checked: hideOther),
- const SizedBox(width: 4),
- Text(AppLocalizations.of(context)!.hideOtherPairs,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ],
- ),
- ),
- const Spacer(),
- GestureDetector(
- onTap: () => _closeAll(context, ref, notifier),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: cs.inverseSurface,
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(AppLocalizations.of(context)!.closeAllPositions,
- style: TextStyle(
- color: cs.onInverseSurface,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ),
- ),
- ],
- ),
- );
- }
- Future<void> _closeAll(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) async {
- if (!_requireLogin(context, ref)) return;
- final l10n = AppLocalizations.of(context)!;
- final confirmed = await _showFuturesConfirm(
- context,
- message: l10n.closeAllConfirm,
- subMessage: l10n.closeAllSubMsg,
- );
- if (!confirmed || !context.mounted) return;
- final err = await notifier.closeAllPositions();
- if (!context.mounted) return;
- final l10n2 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n2) ?? l10n2.closeAllSubmitted,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- }
- class _OrderToolbar extends ConsumerWidget {
- const _OrderToolbar({super.key, required this.symbol});
- final String symbol;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final notifier = ref.read(futuresProvider(symbol).notifier);
- final hideOther =
- ref.watch(futuresProvider(symbol).select((s) => s.hideOtherSymbols));
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- child: Row(
- children: [
- GestureDetector(
- onTap: notifier.toggleHideOtherSymbols,
- child: Row(
- children: [
- _CheckBox(checked: hideOther),
- const SizedBox(width: 4),
- Text(AppLocalizations.of(context)!.hideOtherPairs,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ],
- ),
- ),
- const Spacer(),
- GestureDetector(
- onTap: () => _cancelAll(context, ref, notifier),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: cs.inverseSurface,
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(AppLocalizations.of(context)!.cancelAllOrders,
- style: TextStyle(
- color: cs.onInverseSurface,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ),
- ),
- ],
- ),
- );
- }
- Future<void> _cancelAll(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) async {
- if (!_requireLogin(context, ref)) return;
- final err = await notifier.cancelAllOrders();
- if (!context.mounted) return;
- final l10n3 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n3) ?? l10n3.cancelAllSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- }
- class _BottomTab extends StatelessWidget {
- const _BottomTab({
- required this.label,
- required this.active,
- required this.onTap,
- this.count,
- this.countColor,
- });
- final String label;
- final bool active;
- final VoidCallback onTap;
- /// 数量 badge 文字,如 "(2)"
- final String? count;
- /// badge 文字颜色,默认灰色
- final Color? countColor;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final labelColor = active ? cs.onSurface : cs.onSurface.withAlpha(153);
- return GestureDetector(
- onTap: onTap,
- child: Container(
- padding: const EdgeInsets.symmetric(vertical: 10),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: active ? AppColors.brand : Colors.transparent,
- width: 2,
- ),
- ),
- ),
- child: count != null
- ? RichText(
- text: TextSpan(
- children: [
- TextSpan(
- text: label,
- style: TextStyle(
- color: labelColor,
- fontSize: 13,
- fontWeight: active ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- TextSpan(
- text: count,
- style: TextStyle(
- color: countColor ?? cs.onSurface.withAlpha(153),
- fontSize: 13,
- fontWeight: active ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ],
- ),
- )
- : Text(
- label,
- style: TextStyle(
- color: labelColor,
- fontSize: 13,
- fontWeight: active ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- );
- }
- }
- class _PositionsList extends StatelessWidget {
- const _PositionsList({required this.symbol, required this.positions});
- final String symbol;
- final List<FuturesPosition> positions;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- if (positions.isEmpty) {
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 40),
- child: Center(
- child: Text(AppLocalizations.of(context)!.noPositions,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
- ),
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- itemCount: positions.length,
- itemBuilder: (_, i) => RepaintBoundary(
- key: ValueKey('pos_${positions[i].id}'),
- child: _PositionCard(symbol: symbol, position: positions[i]),
- ),
- );
- }
- }
- class _PositionCard extends ConsumerWidget {
- const _PositionCard({required this.symbol, required this.position});
- final String symbol;
- final FuturesPosition position;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final notifier = ref.read(futuresProvider(symbol).notifier);
- final coinSymbol = _baseCoin(position.symbol);
- final isLong = position.side == OrderSide.long;
- final pnlColor =
- position.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall;
- final sideColor = isLong ? AppColors.rise : AppColors.fall;
- final coinName = coinSymbol;
- // 扁平卡片:border-bottom 分割,padding 10 12,与原型 .pos-card 对齐
- return GestureDetector(
- onTap: () async {
- notifier.stopPolling();
- await context.push('/futures/$symbol/position-detail',
- extra: position);
- if (context.mounted) notifier.resumePolling(symbol);
- },
- child: Container(
- decoration: BoxDecoration(
- border: Border(bottom: BorderSide(color: cs.outline.withAlpha(50))),
- ),
- padding: const EdgeInsets.fromLTRB(12, 10, 12, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── 标题行 ──────────────────────────────────────────
- Row(
- children: [
- // 多/空 彩色徽标
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
- decoration: BoxDecoration(
- color: sideColor,
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- isLong
- ? AppLocalizations.of(context)!.openLong
- : AppLocalizations.of(context)!.openShort,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w700),
- ),
- ),
- const SizedBox(width: 4),
- // Expanded 包裹所有中间信息,分享按钮固定在最右
- Expanded(
- child: Row(
- children: [
- Flexible(
- child: Text(
- '${coinName}USDT',
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w700),
- ),
- ),
- const SizedBox(width: 4),
- _SmallTag(
- text: AppLocalizations.of(context)!.perpetual),
- const SizedBox(width: 3),
- _SmallTag(
- text: _marginModeLabel(position.marginMode,
- AppLocalizations.of(context)!),
- color: _marginModeColor(position.marginMode)),
- const SizedBox(width: 3),
- // 杠杆 黄色徽标
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 5, vertical: 1),
- decoration: BoxDecoration(
- color: AppColors.leverageGoldBg,
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- '${position.leverage.toInt()}X',
- style: const TextStyle(
- color: AppColors.leverageGold,
- fontSize: 10,
- fontWeight: FontWeight.w700),
- ),
- ),
- ],
- ),
- ),
- const SizedBox(width: 8),
- // 分享按钮固定在最右
- GestureDetector(
- onTap: () => _sharePosition(context),
- child: Icon(Icons.share_outlined,
- size: 16, color: cs.onSurface.withAlpha(153)),
- ),
- ],
- ),
- const SizedBox(height: 8),
- // ── 未实现盈亏 + 收益率 ──────────────────────────────
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '${AppLocalizations.of(context)!.unrealizedPnl} (USDT)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 9)),
- Text(
- formatAmount(position.unrealizedPnl),
- style: TextStyle(
- color: pnlColor,
- fontSize: 16,
- fontWeight: FontWeight.w700),
- ),
- ],
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text(AppLocalizations.of(context)!.returnRate,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 9)),
- Text(
- '${formatAmount(position.roe)}%',
- style: TextStyle(
- color: pnlColor,
- fontSize: 13,
- fontWeight: FontWeight.w600),
- ),
- ],
- ),
- ],
- ),
- const SizedBox(height: 6),
- // ── 数据行 1 ────────────────────────────────────────
- Row(
- children: [
- _DataCol(
- label:
- '${AppLocalizations.of(context)!.positionSize}($coinSymbol)',
- value: formatQuantity(position.size),
- align: CrossAxisAlignment.start),
- _DataCol(
- label:
- '${AppLocalizations.of(context)!.marginLabel}(USDT)',
- value: formatAmount(position.margin),
- align: CrossAxisAlignment.center),
- _DataCol(
- label: AppLocalizations.of(context)!.marginRatioLabel,
- value: '${formatAmount(position.marginRatio)}%',
- align: CrossAxisAlignment.end),
- ],
- ),
- const SizedBox(height: 4),
- // ── 数据行 2 ────────────────────────────────────────
- Row(
- children: [
- _DataCol(
- label:
- '${AppLocalizations.of(context)!.openAvgPrice}(USDT)',
- value: formatPrice(position.entryPrice),
- align: CrossAxisAlignment.start),
- _DataCol(
- label:
- '${AppLocalizations.of(context)!.latestLabel}(USDT)',
- value: formatPrice(position.markPrice),
- align: CrossAxisAlignment.center),
- _DataCol(
- label: '${AppLocalizations.of(context)!.liqPrice}(USDT)',
- value: position.liquidationPrice > 0
- ? formatPrice(position.liquidationPrice)
- : '--',
- valueColor: position.liquidationPrice > 0
- ? AppColors.fall
- : cs.onSurface.withAlpha(153),
- align: CrossAxisAlignment.end,
- ),
- ],
- ),
- const SizedBox(height: 8),
- // ── 操作按钮 ──
- Row(
- children: [
- _ActionBtn(
- text: AppLocalizations.of(context)!.takeProfitStopLossBtn,
- onTap: () => _showTpslSheet(context, ref, notifier)),
- const SizedBox(width: 5),
- _ActionBtn(
- text: AppLocalizations.of(context)!.reversePositionBtn,
- onTap: () => _reverse(context, ref, notifier)),
- const SizedBox(width: 5),
- _ActionBtn(
- text: AppLocalizations.of(context)!.closePositionBtn,
- onTap: () => _showCloseSheet(context, ref, notifier)),
- const SizedBox(width: 5),
- Expanded(
- child: SizedBox(
- height: 28,
- child: ElevatedButton(
- onPressed: () => _closeMarket(context, notifier),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(4)),
- elevation: 0,
- padding: EdgeInsets.zero,
- ),
- child: Text(
- AppLocalizations.of(context)!.closeAllMarket,
- style: const TextStyle(
- color: Colors.black,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ),
- ),
- ),
- ],
- ),
- ],
- ),
- )); // GestureDetector + Container
- }
- /// 分享仓位:弹出预览底部弹窗,用户确认后生成图片并调用系统分享
- void _sharePosition(BuildContext context) {
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor: Colors.transparent,
- isScrollControlled: true,
- builder: (_) => SharePositionSheet(position: position),
- );
- }
- Future<void> _closeMarket(
- BuildContext context, FuturesNotifier notifier) async {
- final l10n = AppLocalizations.of(context)!;
- final confirmed = await _showFuturesConfirm(
- context,
- message: l10n.closeAllMarketConfirm,
- subMessage: l10n.closeAllMarketSubMsg,
- );
- if (!confirmed || !context.mounted) return;
- final err = await notifier.closeMarket(position);
- if (!context.mounted) return;
- final l10n4 = AppLocalizations.of(context)!;
- showTopToast(context,
- message:
- resolveProviderError(err, l10n4) ?? l10n4.closeAllMarketSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- void _showCloseSheet(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) {
- if (!_requireLogin(context, ref)) return;
- FocusScope.of(context).unfocus();
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (_) => _ClosePositionSheet(
- position: position, notifier: notifier, symbol: symbol),
- );
- }
- void _showTpslSheet(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) {
- if (!_requireLogin(context, ref)) return;
- FocusScope.of(context).unfocus();
- final pricePrecision =
- ref.read(futuresProvider(symbol).select((s) => s.pricePrecision));
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- isScrollControlled: true,
- backgroundColor: Theme.of(context).colorScheme.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (_) => _TpslSheet(
- position: position,
- notifier: notifier,
- symbol: symbol,
- pricePrecision: pricePrecision),
- );
- }
- Future<void> _reverse(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) async {
- if (!_requireLogin(context, ref)) return;
- final isLong = position.side == OrderSide.long;
- final l10n = AppLocalizations.of(context)!;
- final confirmed = await _showFuturesConfirm(
- context,
- message: l10n.reverseConfirm(
- isLong ? l10n.longLabel : l10n.shortLabel,
- isLong ? l10n.openShort : l10n.openLong,
- ),
- );
- if (!confirmed || !context.mounted) return;
- final err = await notifier.reversePosition(position);
- if (!context.mounted) return;
- final l10n5 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n5) ?? l10n5.reverseSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- }
- /// 自定义 checkbox(方形边框,选中时填充品牌色+勾)
- class _CheckBox extends StatelessWidget {
- const _CheckBox({required this.checked});
- final bool checked;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Container(
- width: 14,
- height: 14,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(3),
- color: checked ? AppColors.brand : Colors.transparent,
- border: Border.all(
- color: checked ? AppColors.brand : cs.onSurface.withAlpha(153),
- width: 1,
- ),
- ),
- child: checked
- ? const Icon(Icons.check, size: 10, color: Colors.white)
- : null,
- );
- }
- }
- class _DataCol extends StatelessWidget {
- const _DataCol({
- required this.label,
- required this.value,
- this.valueColor,
- this.align = CrossAxisAlignment.start,
- });
- final String label;
- final String value;
- final Color? valueColor;
- final CrossAxisAlignment align;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final textAlign = align == CrossAxisAlignment.end
- ? TextAlign.right
- : align == CrossAxisAlignment.center
- ? TextAlign.center
- : TextAlign.left;
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- textAlign: textAlign,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9)),
- Text(value,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- textAlign: textAlign,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 12,
- fontWeight: FontWeight.w500)),
- ],
- ),
- );
- }
- }
- /// 委托卡片数据行:全宽,label 靠左,value 靠右
- class _DataLine extends StatelessWidget {
- const _DataLine({required this.label, required this.value, this.valueColor});
- final String label;
- final String value;
- final Color? valueColor;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Row(
- children: [
- Expanded(
- child: Text(label,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 11)),
- ),
- Text(value,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ],
- );
- }
- }
- class _ActionBtn extends StatelessWidget {
- const _ActionBtn({required this.text, required this.onTap});
- final String text;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Expanded(
- child: SizedBox(
- height: 28,
- child: OutlinedButton(
- onPressed: onTap,
- style: OutlinedButton.styleFrom(
- side: BorderSide(color: cs.outline),
- shape:
- RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
- padding: EdgeInsets.zero,
- ),
- child: Text(text,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 11,
- fontWeight: FontWeight.w500)),
- ),
- ),
- );
- }
- }
- /// 持仓卡片小标签(边框样式,对应原型 .pos-type-tag)
- class _SmallTag extends StatelessWidget {
- const _SmallTag({required this.text, this.color});
- final String text;
- final Color? color;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final c = color;
- if (c != null) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
- decoration: BoxDecoration(
- color: c.withAlpha(isDark ? 45 : 25),
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(text,
- style:
- TextStyle(color: c, fontSize: 9, fontWeight: FontWeight.w500)),
- );
- }
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
- decoration: BoxDecoration(
- border: Border.all(color: cs.outline.withAlpha(80), width: 0.5),
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- text,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 9),
- ),
- );
- }
- }
- class _OrdersList extends ConsumerWidget {
- const _OrdersList({required this.symbol, required this.orders});
- final String symbol;
- final List<FuturesOrder> orders;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final hasMore =
- ref.watch(futuresProvider(symbol).select((s) => s.ordersHasMore));
- final loadingMore =
- ref.watch(futuresProvider(symbol).select((s) => s.ordersLoadingMore));
- if (orders.isEmpty) {
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 40),
- child: Center(
- child: Text(AppLocalizations.of(context)!.noOrders,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13)),
- ),
- );
- }
- final footerCount = (loadingMore || !hasMore) ? 1 : 0;
- return ListView.builder(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- itemCount: orders.length + footerCount,
- itemBuilder: (_, i) {
- if (i < orders.length) {
- return RepaintBoundary(
- key: ValueKey('ord_${orders[i].id}'),
- child: _OrderCard(symbol: symbol, order: orders[i]),
- );
- }
- if (loadingMore) {
- return const Padding(
- padding: EdgeInsets.symmetric(vertical: 12),
- child: Center(
- child: SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(strokeWidth: 2),
- ),
- ),
- );
- }
- return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
- child: Center(
- child: Text(AppLocalizations.of(context)!.allLoaded,
- style:
- TextStyle(color: cs.onSurface.withAlpha(80), fontSize: 11)),
- ),
- );
- },
- );
- }
- }
- class _OrderCard extends ConsumerWidget {
- const _OrderCard({required this.symbol, required this.order});
- final String symbol;
- final FuturesOrder order;
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final cs = Theme.of(context).colorScheme;
- final notifier = ref.read(futuresProvider(symbol).notifier);
- final coinSymbol = _baseCoin(order.symbol);
- // 颜色规则:开多/平空=绿(买方向);开空/平多=红(卖方向)
- final isOpen = order.isOpenOrder;
- final isLong = order.side == OrderSide.long;
- final actionColor = isOpen
- ? (isLong ? AppColors.rise : AppColors.fall)
- : (isLong ? AppColors.fall : AppColors.rise);
- final hasProfit = order.profitPrice != null && order.profitPrice! > 0;
- final hasLoss = order.lossPrice != null && order.lossPrice! > 0;
- // 展示用价格:市价/计划市价→"市价",限价/计划限价→原始值
- String _fmt(double v) =>
- v == v.truncateToDouble() ? '${v.toInt()}' : v.toString();
- final l10n = AppLocalizations.of(context)!;
- final priceText = (order.type == OrderType.market ||
- order.type == OrderType.conditionalMarket)
- ? l10n.marketPrice
- : (order.price > 0 ? _fmt(order.price) : '--');
- // 对应原型 .order-card { border-bottom: 6px solid var(--color-bg-page) }
- return GestureDetector(
- onTap: () async {
- notifier.stopPolling();
- await context.push('/futures/$symbol/order-detail', extra: order);
- if (context.mounted) notifier.resumePolling(symbol);
- },
- child: Container(
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: Theme.of(context).scaffoldBackgroundColor,
- width: 6,
- ),
- ),
- ),
- padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // ── 标题行(与持仓卡片结构一致)──────────────────────────
- Row(
- children: [
- // 开多/开空/平多/平空 — 实心色块 chip
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
- decoration: BoxDecoration(
- color: actionColor,
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- () {
- final isLongSide = order.side == OrderSide.long;
- if (isOpen)
- return isLongSide ? l10n.openLong : l10n.openShort;
- return isLongSide ? l10n.closeLong : l10n.closeShort;
- }(),
- style: const TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w700),
- ),
- ),
- const SizedBox(width: 4),
- // Expanded 包裹币对+标签,撤单按钮固定在最右
- Expanded(
- child: Row(
- children: [
- Flexible(
- child: Text(
- order.symbol.toUpperCase(),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w700),
- ),
- ),
- const SizedBox(width: 4),
- _SmallTag(
- text: order.type == OrderType.market
- ? l10n.marketHint
- : order.type == OrderType.limit
- ? l10n.limitLabel
- : l10n.planOrderLabel),
- const SizedBox(width: 3),
- _SmallTag(
- text: _marginModeLabel(order.marginMode, l10n),
- color: _marginModeColor(order.marginMode)),
- const SizedBox(width: 3),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 5, vertical: 1),
- decoration: BoxDecoration(
- color: AppColors.leverageGoldBg,
- borderRadius: BorderRadius.circular(3),
- ),
- child: Text(
- '${order.leverage.toInt()}X',
- style: const TextStyle(
- color: AppColors.leverageGold,
- fontSize: 10,
- fontWeight: FontWeight.w700),
- ),
- ),
- if (hasProfit) ...[
- const SizedBox(width: 4),
- _TpSlChip(
- label: l10n.takeProfit, color: AppColors.rise),
- ],
- if (hasLoss) ...[
- const SizedBox(width: 4),
- _TpSlChip(
- label: l10n.stopLoss, color: AppColors.fall),
- ],
- ],
- ),
- ),
- const SizedBox(width: 8),
- // 撤单按钮固定在最右
- GestureDetector(
- onTap: () => _cancelOrder(context, ref, notifier),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8, vertical: 3),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: cs.outline),
- ),
- child: Text(l10n.cancelOrder,
- style: TextStyle(color: cs.onSurface, fontSize: 11)),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- // ── 数据块(label 靠左,value 靠右,全宽行)────────────
- _DataLine(label: l10n.orderPriceLabel, value: priceText),
- const SizedBox(height: 3),
- _DataLine(
- label: l10n.orderSizeCoin(coinSymbol),
- value: formatQuantity(order.size)),
- const SizedBox(height: 3),
- _DataLine(
- label: l10n.filledSizeCoin(coinSymbol),
- value: formatQuantity(order.filledSize)),
- if (order.triggerPrice > 0) ...[
- const SizedBox(height: 3),
- _DataLine(
- label: '${l10n.triggerPrice}(USDT)',
- value: formatPrice(order.triggerPrice)),
- ],
- if (hasProfit) ...[
- const SizedBox(height: 3),
- _DataLine(
- label: l10n.takeProfitPrice,
- value: formatPrice(order.profitPrice!),
- valueColor: AppColors.rise),
- ],
- if (hasLoss) ...[
- const SizedBox(height: 3),
- _DataLine(
- label: l10n.stopLossPrice,
- value: formatPrice(order.lossPrice!),
- valueColor: AppColors.fall),
- ],
- ],
- ),
- )); // GestureDetector + Container
- }
- Future<void> _cancelOrder(
- BuildContext context, WidgetRef ref, FuturesNotifier notifier) async {
- if (!_requireLogin(context, ref)) return;
- final err = await notifier.cancelOrder(order);
- if (!context.mounted) return;
- final l10n6 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n6) ?? l10n6.cancelOrderSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- }
- /// 止盈止损指示小标签(行内 chip)
- class _TpSlChip extends StatelessWidget {
- const _TpSlChip({required this.label, required this.color});
- final String label;
- final Color color;
- @override
- Widget build(BuildContext context) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
- decoration: BoxDecoration(
- color: color.withAlpha(28),
- borderRadius: BorderRadius.circular(3),
- border: Border.all(color: color.withAlpha(120), width: 0.5),
- ),
- child: Text(
- label,
- style:
- TextStyle(color: color, fontSize: 9, fontWeight: FontWeight.w600),
- ),
- );
- }
- }
- class _TpslSheet extends ConsumerStatefulWidget {
- const _TpslSheet(
- {required this.position,
- required this.notifier,
- required this.symbol,
- required this.pricePrecision});
- final FuturesPosition position;
- final FuturesNotifier notifier;
- final String symbol;
- final int pricePrecision;
- @override
- ConsumerState<_TpslSheet> createState() => _TpslSheetState();
- }
- class _TpslSheetState extends ConsumerState<_TpslSheet> {
- late final TextEditingController _tpController;
- late final TextEditingController _slController;
- bool _tpEnabled = true;
- bool _slEnabled = true;
- @override
- void initState() {
- super.initState();
- final pos = widget.position;
- final p = widget.pricePrecision;
- _tpController = TextEditingController(
- text: pos.profitPrice != null && pos.profitPrice! > 0
- ? pos.profitPrice!.toStringAsFixed(p)
- : '');
- _slController = TextEditingController(
- text: pos.lossPrice != null && pos.lossPrice! > 0
- ? pos.lossPrice!.toStringAsFixed(p)
- : '');
- _tpEnabled = pos.profitPrice != null && pos.profitPrice! > 0 ? true : true;
- _slEnabled = pos.lossPrice != null && pos.lossPrice! > 0 ? true : true;
- }
- @override
- void dispose() {
- _tpController.dispose();
- _slController.dispose();
- super.dispose();
- }
- double get _entryPrice => widget.position.entryPrice;
- double get _availableSize => widget.position.availableSize;
- bool get _isLong => widget.position.side == OrderSide.long;
- /// 预估盈利(止盈触发时)
- double? _estimatedProfit() {
- final tp = double.tryParse(_tpController.text);
- if (tp == null || tp <= 0) return null;
- return _isLong
- ? (tp - _entryPrice) * _availableSize
- : (_entryPrice - tp) * _availableSize;
- }
- /// 预估亏损(止损触发时)
- double? _estimatedLoss() {
- final sl = double.tryParse(_slController.text);
- if (sl == null || sl <= 0) return null;
- return _isLong
- ? (sl - _entryPrice) * _availableSize
- : (_entryPrice - sl) * _availableSize;
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- // 标记价格取仓位自身的 markPrice(仓位持有的币种),而非当前切换到的币对
- final markPrice = widget.position.markPrice;
- final coinPrecision = ref
- .watch(futuresProvider(widget.symbol).select((s) => s.coinPrecision));
- final pricePrecision = widget.pricePrecision;
- final l10n = AppLocalizations.of(context)!;
- final coinSymbol = _baseCoin(widget.position.symbol);
- final isLong = _isLong;
- final position = widget.position;
- final sideLabel = isLong ? l10n.longHeadLabel : l10n.shortHeadLabel;
- final leverageLabel = '${position.leverage.toInt()}X';
- final coinName = coinSymbol;
- final profit = _estimatedProfit();
- final loss = _estimatedLoss();
- return Padding(
- padding: EdgeInsets.only(
- bottom: MediaQuery.of(context).viewInsets.bottom + 24,
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // ── 头部 ──
- Row(
- children: [
- const SizedBox(width: 40),
- Expanded(
- child: Column(
- children: [
- const SizedBox(height: 16),
- Text(l10n.takeProfitStopLoss,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600)),
- const SizedBox(height: 2),
- Text(
- '${coinName}${l10n.perpetual} $sideLabel $leverageLabel ${_marginModeLabel(position.marginMode, l10n)}',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12),
- ),
- const SizedBox(height: 4),
- ],
- ),
- ),
- SizedBox(
- width: 40,
- child: IconButton(
- onPressed: () => Navigator.pop(context),
- icon: Icon(Icons.close,
- color: cs.onSurface.withAlpha(153), size: 20),
- ),
- ),
- ],
- ),
- // ── 开仓均价 | 标记价格 ──
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${l10n.openAvgPrice}(USDT)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(formatPrice(_entryPrice),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text('${l10n.markLabel}(USDT)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(120),
- fontSize: 11)),
- const SizedBox(height: 2),
- Text(formatPrice(markPrice > 0 ? markPrice : _entryPrice),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ],
- ),
- ),
- ],
- ),
- ),
- // ── 复选框行 ──
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Row(
- children: [
- _TpslCheckbox(
- label: l10n.setTakeProfit,
- value: _tpEnabled,
- onChanged: (v) => setState(() => _tpEnabled = v),
- ),
- const SizedBox(width: 24),
- _TpslCheckbox(
- label: l10n.setStopLoss,
- value: _slEnabled,
- onChanged: (v) => setState(() => _slEnabled = v),
- ),
- ],
- ),
- ),
- const SizedBox(height: 14),
- // ── 止盈输入 ──
- if (_tpEnabled) ...[
- _TpslInput(
- controller: _tpController,
- hint: l10n.tpTriggerPrice,
- pricePrecision: pricePrecision,
- onLatest: () {
- final price = markPrice > 0 ? markPrice : _entryPrice;
- final text = price.toStringAsFixed(pricePrecision);
- _tpController.value = TextEditingValue(
- text: text,
- selection: TextSelection.collapsed(offset: text.length),
- );
- setState(() {});
- },
- onChanged: (_) => setState(() {}),
- ),
- const SizedBox(height: 10),
- ],
- // ── 止损输入 ──
- if (_slEnabled) ...[
- _TpslInput(
- controller: _slController,
- hint: l10n.slTriggerPrice,
- pricePrecision: pricePrecision,
- onLatest: () {
- final price = markPrice > 0 ? markPrice : _entryPrice;
- final text = price.toStringAsFixed(pricePrecision);
- _slController.value = TextEditingValue(
- text: text,
- selection: TextSelection.collapsed(offset: text.length),
- );
- setState(() {});
- },
- onChanged: (_) => setState(() {}),
- ),
- const SizedBox(height: 10),
- ],
- // ── 数据行 ──
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
- child: Column(
- children: [
- _TpslDataRow(
- label: l10n.closeableSizeCoin(coinSymbol),
- value: _availableSize > 0
- ? formatAmount(_availableSize, decimals: coinPrecision)
- : '--',
- valueColor: cs.onSurface,
- ),
- const SizedBox(height: 8),
- _TpslDataRow(
- label: l10n.estProfit,
- value: profit != null
- ? '${profit >= 0 ? '+' : ''}${formatPrice(profit)}'
- : '0.00',
- valueColor: profit != null && profit >= 0
- ? AppColors.rise
- : AppColors.fall,
- ),
- const SizedBox(height: 8),
- _TpslDataRow(
- label: l10n.estLoss,
- value: loss != null
- ? '${loss >= 0 ? '+' : ''}${formatPrice(loss)}'
- : '0.00',
- valueColor: loss != null && loss < 0
- ? AppColors.fall
- : AppColors.rise,
- ),
- ],
- ),
- ),
- const SizedBox(height: 16),
- // ── 确定按钮 ──
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
- child: SizedBox(
- width: double.infinity,
- height: 48,
- child: ElevatedButton(
- onPressed: () => _submit(context),
- style: ElevatedButton.styleFrom(
- backgroundColor: cs.inverseSurface,
- elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- ),
- child: Text(l10n.confirmLabel,
- style: TextStyle(
- color: cs.onInverseSurface,
- fontSize: 15,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ),
- ],
- ),
- );
- }
- Future<void> _submit(BuildContext context) async {
- final tp = _tpEnabled ? double.tryParse(_tpController.text) : null;
- final sl = _slEnabled ? double.tryParse(_slController.text) : null;
- if (tp == null && sl == null) {
- Navigator.pop(context);
- return;
- }
- final err = await widget.notifier.setPositionTpsl(
- widget.position,
- profitPrice: tp,
- lossPrice: sl,
- );
- if (!context.mounted) return;
- Navigator.pop(context);
- final l10n7 = AppLocalizations.of(context)!;
- showTopToast(context,
- message: resolveProviderError(err, l10n7) ?? l10n7.tpslSuccess,
- backgroundColor: err != null ? AppColors.fall : AppColors.rise);
- }
- }
- class _TpslCheckbox extends StatelessWidget {
- const _TpslCheckbox(
- {required this.label, required this.value, required this.onChanged});
- final String label;
- final bool value;
- final ValueChanged<bool> onChanged;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return GestureDetector(
- onTap: () => onChanged(!value),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- width: 16,
- height: 16,
- decoration: BoxDecoration(
- color: value
- ? AppColors.brand
- : (isDark ? Colors.white : Colors.transparent),
- borderRadius: BorderRadius.circular(3),
- border: Border.all(
- color: value ? AppColors.brand : cs.onSurface.withAlpha(220),
- width: 1.5,
- ),
- ),
- child: value
- ? const Icon(Icons.check, size: 12, color: Colors.black)
- : null,
- ),
- const SizedBox(width: 6),
- Text(label, style: TextStyle(color: cs.onSurface, fontSize: 13)),
- ],
- ),
- );
- }
- }
- class _TpslInput extends StatefulWidget {
- const _TpslInput({
- required this.controller,
- required this.hint,
- required this.pricePrecision,
- required this.onLatest,
- required this.onChanged,
- });
- final TextEditingController controller;
- final String hint;
- final int pricePrecision;
- final VoidCallback onLatest;
- final ValueChanged<String> onChanged;
- @override
- State<_TpslInput> createState() => _TpslInputState();
- }
- class _TpslInputState extends State<_TpslInput> {
- final _focusNode = FocusNode();
- @override
- void initState() {
- super.initState();
- _focusNode.addListener(() {
- if (mounted) setState(() {});
- });
- }
- @override
- void dispose() {
- _focusNode.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final isFocused = _focusNode.hasFocus;
- final bgColor = isFocused
- ? (isDark ? AppColors.darkBgSecondary : Colors.white)
- : (isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary);
- final activeBorder = isDark
- ? AppColors.darkTextPrimary.withAlpha(200)
- : const Color(0xFF383838);
- return Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: Row(
- children: [
- Expanded(
- child: Container(
- height: 44,
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: isFocused ? activeBorder : cs.onSurface.withAlpha(40),
- width: isFocused ? 1.5 : 1,
- ),
- ),
- child: Row(
- children: [
- Padding(
- padding: const EdgeInsets.only(left: 12),
- child: Text(widget.hint,
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 13)),
- ),
- Expanded(
- child: TextField(
- controller: widget.controller,
- focusNode: _focusNode,
- keyboardType:
- const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- _PrecisionInputFormatter(widget.pricePrecision)
- ],
- onChanged: widget.onChanged,
- textAlign: TextAlign.right,
- decoration: InputDecoration(
- border: InputBorder.none,
- focusedBorder: InputBorder.none,
- enabledBorder: InputBorder.none,
- filled: false,
- contentPadding:
- const EdgeInsets.symmetric(horizontal: 8),
- ),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w600),
- ),
- ),
- Padding(
- padding: const EdgeInsets.only(right: 12),
- child: Text('USDT',
- style: TextStyle(
- color: cs.onSurface.withAlpha(120), fontSize: 12)),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: widget.onLatest,
- child: Container(
- height: 44,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- decoration: BoxDecoration(
- color: cs.inverseSurface,
- borderRadius: BorderRadius.circular(8),
- ),
- alignment: Alignment.center,
- child: Text(AppLocalizations.of(context)!.latestLabel,
- style: TextStyle(
- color: cs.onInverseSurface,
- fontSize: 13,
- fontWeight: FontWeight.w500)),
- ),
- ),
- ],
- ),
- );
- }
- }
- class _TpslDataRow extends StatelessWidget {
- const _TpslDataRow(
- {required this.label, required this.value, required this.valueColor});
- final String label;
- final String value;
- final Color valueColor;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(label,
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)),
- Text(value,
- style: TextStyle(
- color: valueColor, fontSize: 12, fontWeight: FontWeight.w500)),
- ],
- );
- }
- }
- class _ClosePositionSheet extends ConsumerStatefulWidget {
- const _ClosePositionSheet(
- {required this.position, required this.notifier, required this.symbol});
- final FuturesPosition position;
- final FuturesNotifier notifier;
- final String symbol;
- @override
- ConsumerState<_ClosePositionSheet> createState() =>
- _ClosePositionSheetState();
- }
- class _ClosePositionSheetState extends ConsumerState<_ClosePositionSheet> {
- final _priceController = TextEditingController();
- final _volumeController = TextEditingController();
- final _priceFocusNode = FocusNode();
- final _volumeFocusNode = FocusNode();
- bool _isMarket = false; // false=限价, true=市价
- double _percent = 0;
- String _baseCoin(String sym) {
- if (sym.contains('/')) return sym.split('/').first;
- return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- }
- @override
- void initState() {
- super.initState();
- _priceFocusNode.addListener(() {
- if (mounted) setState(() {});
- });
- _volumeFocusNode.addListener(() {
- if (mounted) setState(() {});
- });
- }
- @override
- void dispose() {
- _priceController.dispose();
- _volumeController.dispose();
- _priceFocusNode.dispose();
- _volumeFocusNode.dispose();
- super.dispose();
- }
- void _onPercentChanged(double p) {
- setState(() => _percent = p);
- final avail = widget.position.availableSize;
- final vol = avail * p;
- if (vol <= 0) {
- _volumeController.clear();
- } else {
- final precision = ref
- .read(futuresProvider(widget.symbol).select((s) => s.coinPrecision));
- _volumeController.text = vol.toStringAsFixed(precision);
- }
- }
- void _syncPercentFromVolume() {
- final avail = widget.position.availableSize;
- if (avail <= 0) return;
- final vol =
- double.tryParse(_volumeController.text.replaceAll(',', '')) ?? 0;
- setState(() => _percent = (vol / avail).clamp(0.0, 1.0));
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final inputUnfocusedBg =
- isDark ? AppColors.darkBgTertiary : AppColors.lightBgSecondary;
- final inputFocusedBg = isDark ? AppColors.darkBgSecondary : Colors.white;
- final inputActiveBorder = isDark
- ? AppColors.darkTextPrimary.withAlpha(200)
- : const Color(0xFF383838);
- final coinPrecision = ref
- .watch(futuresProvider(widget.symbol).select((s) => s.coinPrecision));
- final pricePrecision = ref
- .watch(futuresProvider(widget.symbol).select((s) => s.pricePrecision));
- final pos = widget.position;
- final l10n = AppLocalizations.of(context)!;
- final isLong = pos.side == OrderSide.long;
- final sideLabel = isLong ? l10n.longHeadLabel : l10n.shortHeadLabel;
- final leverage = pos.leverage.toInt();
- final availSize = pos.availableSize;
- final coinSymbol = _baseCoin(pos.symbol);
- // 预计盈亏:限价用输入价格,市价用标记价格估算
- double? estPnl;
- final closeVol =
- double.tryParse(_volumeController.text.replaceAll(',', '')) ?? 0;
- if (closeVol > 0) {
- if (_isMarket) {
- // 市价:以标记价格估算盈亏
- final diff = isLong
- ? (pos.markPrice - pos.entryPrice)
- : (pos.entryPrice - pos.markPrice);
- estPnl = diff * closeVol;
- } else {
- final price = double.tryParse(_priceController.text);
- if (price != null && price > 0) {
- // 多头平仓:卖出价 - 开仓价;空头平仓:开仓价 - 买入价
- final diff =
- isLong ? (price - pos.entryPrice) : (pos.entryPrice - price);
- estPnl = diff * closeVol;
- }
- }
- }
- return Padding(
- padding: EdgeInsets.only(
- left: 16,
- right: 16,
- top: 20,
- bottom: MediaQuery.of(context).viewInsets.bottom + 24,
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // ── 标题行 ────────────────────────────────────────────
- SizedBox(
- width: double.infinity,
- child: Stack(
- alignment: Alignment.center,
- children: [
- Text(l10n.closePositionBtn,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600)),
- Positioned(
- right: 0,
- child: GestureDetector(
- onTap: () => Navigator.pop(context),
- child: Icon(Icons.close,
- size: 20, color: cs.onSurface.withAlpha(153)),
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 4),
- Text(
- '${pos.symbol} ${l10n.perpetual} $sideLabel ${leverage}X ${_marginModeLabel(pos.marginMode, l10n)}',
- style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
- ),
- const SizedBox(height: 16),
- // ── 开仓均价 / 标记价格(带边框盒子,对应原型设计)────
- Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text('${l10n.openAvgPrice}(USDT)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- Text(formatAmount(pos.entryPrice),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500)),
- ],
- ),
- ),
- Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- Text('${l10n.markLabel}(USDT)',
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 11)),
- const SizedBox(height: 2),
- GestureDetector(
- onTap: _isMarket
- ? null
- : () {
- final text =
- pos.markPrice.toStringAsFixed(pricePrecision);
- _priceController.value = TextEditingValue(
- text: text,
- selection:
- TextSelection.collapsed(offset: text.length),
- );
- setState(() {});
- },
- child: Text(formatAmount(pos.markPrice),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 14,
- fontWeight: FontWeight.w500)),
- ),
- ],
- ),
- ],
- ),
- const SizedBox(height: 12),
- // ── 价格输入 + 限价/市价切换 ─────────────────────────
- Row(
- children: [
- Expanded(
- child: Container(
- height: 46,
- decoration: BoxDecoration(
- color: _isMarket
- ? inputUnfocusedBg
- : (_priceFocusNode.hasFocus
- ? inputFocusedBg
- : inputUnfocusedBg),
- borderRadius: BorderRadius.circular(8),
- border: !_isMarket && _priceFocusNode.hasFocus
- ? Border.all(color: inputActiveBorder, width: 1.5)
- : Border.all(
- color: cs.onSurface.withAlpha(40), width: 1),
- ),
- child: TextField(
- controller: _priceController,
- focusNode: _priceFocusNode,
- enabled: !_isMarket,
- keyboardType:
- const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [_PrecisionInputFormatter(pricePrecision)],
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- onChanged: (_) => setState(() {}),
- decoration: InputDecoration(
- hintText:
- _isMarket ? l10n.marketHint : l10n.pricePlaceholder,
- hintStyle: TextStyle(
- color: cs.onSurface.withAlpha(100), fontSize: 14),
- suffixText: 'USDT',
- suffixStyle: TextStyle(
- color: cs.onSurface.withAlpha(100), fontSize: 12),
- filled: false,
- contentPadding:
- const EdgeInsets.symmetric(horizontal: 12),
- border: InputBorder.none,
- focusedBorder: InputBorder.none,
- enabledBorder: InputBorder.none,
- disabledBorder: InputBorder.none,
- ),
- ),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () => setState(() {
- _isMarket = !_isMarket;
- _priceController.clear();
- }),
- child: Container(
- height: 46,
- padding: const EdgeInsets.symmetric(horizontal: 16),
- decoration: BoxDecoration(
- color: AppColors.brand,
- borderRadius: BorderRadius.circular(8),
- ),
- alignment: Alignment.center,
- child: Text(
- _isMarket ? l10n.marketHint : l10n.limitLabel,
- style: TextStyle(
- color: Colors.black,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ],
- ),
- if (!_isMarket) ...[
- const SizedBox(height: 4),
- Align(
- alignment: Alignment.centerLeft,
- child: Text(
- isLong ? l10n.closePositionMsgLong : l10n.closePositionMsgShort,
- style:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 11),
- ),
- ),
- ],
- const SizedBox(height: 8),
- // ── 平仓数量输入(带币种后缀)────────────────────────
- Container(
- height: 46,
- decoration: BoxDecoration(
- color:
- _volumeFocusNode.hasFocus ? inputFocusedBg : inputUnfocusedBg,
- borderRadius: BorderRadius.circular(8),
- border: _volumeFocusNode.hasFocus
- ? Border.all(color: inputActiveBorder, width: 1.5)
- : Border.all(color: cs.onSurface.withAlpha(40), width: 1),
- ),
- child: TextField(
- controller: _volumeController,
- focusNode: _volumeFocusNode,
- keyboardType:
- const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [_PrecisionInputFormatter(coinPrecision)],
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- onChanged: (_) => _syncPercentFromVolume(),
- decoration: InputDecoration(
- hintText: l10n.enterCloseVolume,
- hintStyle:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14),
- suffixText: coinSymbol,
- suffixStyle:
- TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 12),
- filled: false,
- contentPadding: const EdgeInsets.symmetric(horizontal: 12),
- border: InputBorder.none,
- focusedBorder: InputBorder.none,
- enabledBorder: InputBorder.none,
- ),
- ),
- ),
- const SizedBox(height: 12),
- // ── 滑动条 ──────────────────────────────────────────
- _PercentSlider(
- percent: _percent,
- onChanged: _onPercentChanged,
- ),
- const SizedBox(height: 12),
- // ── 可平量 / 预计盈亏 ─────────────────────────────────
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(l10n.closeableSizeCoin(coinSymbol),
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12)),
- Text(formatAmount(availSize, decimals: coinPrecision),
- style: TextStyle(color: cs.onSurface, fontSize: 12)),
- ],
- ),
- const SizedBox(height: 6),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(l10n.estPnlLabel,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153), fontSize: 12)),
- Text(
- estPnl == null
- ? '-- USDT'
- : '${estPnl >= 0 ? '+' : ''}${formatAmount(estPnl)} USDT',
- style: TextStyle(
- color: estPnl == null
- ? cs.onSurface.withAlpha(153)
- : (estPnl >= 0 ? AppColors.rise : AppColors.fall),
- fontSize: 12,
- fontWeight: FontWeight.w500,
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- // ── 确定按钮 ──────────────────────────────────────────
- SizedBox(
- width: double.infinity,
- height: 52,
- child: ElevatedButton(
- onPressed: () => _close(context),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(26),
- ),
- ),
- child: Text(
- l10n.confirmLabel,
- style: TextStyle(
- color: Colors.black,
- fontSize: 16,
- fontWeight: FontWeight.w600),
- ),
- ),
- ),
- ],
- ),
- );
- }
- Future<void> _close(BuildContext context) async {
- final volume = double.tryParse(_volumeController.text.replaceAll(',', ''));
- // 数量为空或0不允许提交
- if (volume == null || volume <= 0) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.enterCloseVolume,
- backgroundColor: AppColors.fall);
- return;
- }
- String? err;
- if (_isMarket) {
- err = await widget.notifier.closeMarket(widget.position, volume: volume);
- } else {
- final price = double.tryParse(_priceController.text) ?? 0;
- // 限价单必须输入价格,未填则提示且不关闭弹窗
- if (price <= 0) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.enterLimitPrice,
- backgroundColor: AppColors.fall);
- return;
- }
- err = await widget.notifier
- .closeLimit(widget.position, price, volume: volume);
- }
- if (!context.mounted) return;
- final l10n8 = AppLocalizations.of(context)!;
- if (err != null) {
- showTopToast(context,
- message: resolveProviderError(err, l10n8) ?? err,
- backgroundColor: AppColors.fall);
- return;
- }
- Navigator.pop(context);
- showTopToast(context,
- message: l10n8.closeOrderSubmitted, backgroundColor: AppColors.rise);
- }
- }
- class _AssetsPanel extends StatelessWidget {
- const _AssetsPanel({required this.info});
- final FuturesAccountInfo info;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return Padding(
- padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(AppLocalizations.of(context)!.contractAccountUsdt,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)),
- const SizedBox(height: 4),
- Text(
- formatAmount(info.totalBalance),
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 22,
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 10),
- Row(
- children: [
- _AssetItem(
- label: AppLocalizations.of(context)!.availableMargin,
- value: formatAmount(info.availableMargin),
- align: CrossAxisAlignment.start),
- _AssetItem(
- label: AppLocalizations.of(context)!.usedMargin,
- value: formatAmount(info.usedMargin),
- align: CrossAxisAlignment.center),
- _AssetItem(
- label: AppLocalizations.of(context)!.unrealizedPnl,
- value:
- '${info.unrealizedPnl >= 0 ? '+' : ''}${formatAmount(info.unrealizedPnl)}',
- valueColor:
- info.unrealizedPnl >= 0 ? AppColors.rise : AppColors.fall,
- align: CrossAxisAlignment.end,
- ),
- ],
- ),
- ],
- ),
- );
- }
- }
- class _AssetItem extends StatelessWidget {
- const _AssetItem({
- required this.label,
- required this.value,
- this.valueColor,
- this.align = CrossAxisAlignment.start,
- });
- final String label;
- final String value;
- final Color? valueColor;
- final CrossAxisAlignment align;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final textAlign = align == CrossAxisAlignment.end
- ? TextAlign.right
- : align == CrossAxisAlignment.center
- ? TextAlign.center
- : TextAlign.left;
- return Expanded(
- child: Column(
- crossAxisAlignment: align,
- children: [
- Text(label,
- textAlign: textAlign,
- style:
- TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 10)),
- const SizedBox(height: 2),
- Text(value,
- textAlign: textAlign,
- style: TextStyle(
- color: valueColor ?? cs.onSurface,
- fontSize: 13,
- fontWeight: FontWeight.w500)),
- ],
- ),
- );
- }
- }
- double _toDouble(dynamic v) {
- if (v == null) return 0.0;
- if (v is num) return v.toDouble();
- return double.tryParse(v.toString()) ?? 0.0;
- }
- /// AppBar 顶部"现货 / 永续合约"切换 Tab
- /// 与 spot_screen.dart 中的 _SegmentedTabHeader 保持一致样式
- class _SpotFuturesTabHeader extends StatelessWidget {
- const _SpotFuturesTabHeader({required this.activeIndex, required this.onTap});
- final int activeIndex; // 0=现货 1=合约
- final ValueChanged<int> onTap;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final items = [l10n.spotTab, l10n.perpetualContract];
- return Row(
- mainAxisSize: MainAxisSize.min,
- children: List.generate(items.length, (i) {
- final isActive = i == activeIndex;
- return Padding(
- padding: EdgeInsets.only(right: i == 0 ? 16 : 0),
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: () => onTap(i),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- items[i],
- style: TextStyle(
- color:
- isActive ? cs.onSurface : cs.onSurface.withAlpha(140),
- fontSize: 16,
- fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
- ),
- ),
- const SizedBox(height: 4),
- Container(
- width: 28,
- height: 3,
- color: isActive ? AppColors.brand : Colors.transparent,
- ),
- ],
- ),
- ),
- );
- }),
- );
- }
- }
- /// 精度输入格式化器
- /// [decimals] = 0 时只允许正整数;> 0 时允许最多 [decimals] 位小数
- class _PrecisionInputFormatter extends TextInputFormatter {
- const _PrecisionInputFormatter(this.decimals);
- final int decimals;
- @override
- TextEditingValue formatEditUpdate(
- TextEditingValue oldValue, TextEditingValue newValue) {
- final text = newValue.text;
- if (text.isEmpty) return newValue;
- if (decimals == 0) {
- // 只允许正整数
- if (!RegExp(r'^\d+$').hasMatch(text)) return oldValue;
- return newValue;
- }
- // 允许小数:最多 [decimals] 位
- final pattern = RegExp(r'^\d+\.?\d{0,' + decimals.toString() + r'}$');
- if (!pattern.hasMatch(text)) return oldValue;
- // 不允许以多个小数点开头
- if (text.indexOf('.') != text.lastIndexOf('.')) return oldValue;
- return newValue;
- }
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // 仓位分享相关 widget
- // ══════════════════════════════════════════════════════════════════════════════
- /// 分享底部弹窗:预览分享卡片 + 操作按钮(供资产页复用)
- class SharePositionSheet extends ConsumerStatefulWidget {
- const SharePositionSheet({super.key, required this.position});
- final FuturesPosition position;
- @override
- ConsumerState<SharePositionSheet> createState() => _SharePositionSheetState();
- }
- class _SharePositionSheetState extends ConsumerState<SharePositionSheet> {
- final _cardKey = GlobalKey();
- bool _sharing = false;
- bool _saving = false;
- String? _inviteCode;
- String? _inviteUrl;
- @override
- void initState() {
- super.initState();
- _loadInviteInfo();
- }
- Future<void> _loadInviteInfo() async {
- try {
- final dio = ref.read(dioClientProvider);
- final data = await AuthService(dio).getMyInfo();
- final prefix = data['promotionPrefix']?.toString() ?? '';
- final code = data['promotionCode']?.toString() ?? '';
- final url =
- (prefix.isNotEmpty || code.isNotEmpty) ? '$prefix$code' : null;
- if (mounted) {
- setState(() {
- _inviteCode = code.isNotEmpty ? code : null;
- _inviteUrl = url;
- });
- }
- } catch (e) {
- print('[ShareCard] _loadInviteInfo error: $e');
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final pos = widget.position;
- final pnlPositive = pos.unrealizedPnl >= 0;
- final l10n = AppLocalizations.of(context)!;
- return Container(
- decoration: BoxDecoration(
- color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
- ),
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- // 拖拽指示条
- Container(
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: cs.onSurface.withAlpha(60),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- const SizedBox(height: 16),
- // 分享卡片预览
- RepaintBoundary(
- key: _cardKey,
- child: _PositionShareCard(
- position: pos,
- inviteCode: _inviteCode,
- inviteUrl: _inviteUrl,
- ),
- ),
- const SizedBox(height: 24),
- // 操作按钮行:取消 | 保存海报 | 分享
- Row(
- children: [
- Expanded(
- child: OutlinedButton(
- onPressed: () => Navigator.of(context).pop(),
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- ),
- child: Text(l10n.cancelLabel,
- style: TextStyle(color: cs.onSurface, fontSize: 14)),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: OutlinedButton(
- onPressed: _saving ? null : () => _doSave(context),
- style: OutlinedButton.styleFrom(
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- ),
- child: _saving
- ? SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: cs.onSurface.withAlpha(153)),
- )
- : Text(l10n.savePoster,
- style: TextStyle(color: cs.onSurface, fontSize: 14)),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: ElevatedButton(
- onPressed: _sharing ? null : () => _doShare(context),
- style: ElevatedButton.styleFrom(
- backgroundColor:
- pnlPositive ? AppColors.rise : AppColors.fall,
- padding: const EdgeInsets.symmetric(vertical: 12),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8)),
- elevation: 0,
- ),
- child: _sharing
- ? const SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(
- strokeWidth: 2, color: Colors.white),
- )
- : Text(l10n.shareLabel,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 14,
- fontWeight: FontWeight.w600)),
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- Future<Uint8List?> _renderCard() async {
- final boundary =
- _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
- if (boundary == null) return null;
- final image = await boundary.toImage(pixelRatio: 3.0);
- final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
- return byteData?.buffer.asUint8List();
- }
- Future<void> _doSave(BuildContext context) async {
- setState(() => _saving = true);
- try {
- final bytes = await _renderCard();
- if (bytes == null) return;
- // 申请权限(首次弹系统弹窗;已拒绝则 false,但仍尝试写入让 GalException 决定)
- await Gal.requestAccess();
- await Gal.putImageBytes(
- bytes,
- name: 'position_share_${DateTime.now().millisecondsSinceEpoch}',
- );
- if (!context.mounted) return;
- showTopToast(context,
- message: AppLocalizations.of(context)!.saveSuccess,
- backgroundColor: AppColors.rise);
- } on GalException catch (e) {
- if (!context.mounted) return;
- // accessDenied 时引导用户去设置开启权限
- final l10n = AppLocalizations.of(context)!;
- if (e.type == GalExceptionType.accessDenied) {
- showTopToast(context,
- message: l10n.photoPermissionDenied,
- backgroundColor: AppColors.fall);
- } else {
- showTopToast(context,
- message: l10n.saveFailed, backgroundColor: AppColors.fall);
- }
- } catch (e) {
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.saveFailed,
- backgroundColor: AppColors.fall);
- }
- } finally {
- if (mounted) setState(() => _saving = false);
- }
- }
- Future<void> _doShare(BuildContext context) async {
- setState(() => _sharing = true);
- try {
- final bytes = await _renderCard();
- if (bytes == null) return;
- final tmpDir = await getTemporaryDirectory();
- final file = File(
- '${tmpDir.path}/position_share_${DateTime.now().millisecondsSinceEpoch}.png');
- await file.writeAsBytes(bytes);
- if (!context.mounted) return;
- Navigator.of(context).pop();
- await Share.shareXFiles(
- [XFile(file.path, mimeType: 'image/png')],
- subject: AppLocalizations.of(context)!.myFuturesPosition,
- );
- } catch (e) {
- if (context.mounted) {
- showTopToast(context,
- message: AppLocalizations.of(context)!.shareFailed,
- backgroundColor: AppColors.fall);
- }
- } finally {
- if (mounted) setState(() => _sharing = false);
- }
- }
- }
- /// 分享卡片内容
- class _PositionShareCard extends StatelessWidget {
- const _PositionShareCard({
- required this.position,
- this.inviteCode,
- this.inviteUrl,
- });
- final FuturesPosition position;
- final String? inviteCode;
- final String? inviteUrl;
- String _baseCoin(String sym) {
- if (sym.contains('/')) return sym.split('/').first;
- return sym.toUpperCase().replaceFirst(RegExp(r'USDT$'), '');
- }
- String _formatNow() {
- final t = DateTime.now();
- final mo = t.month.toString().padLeft(2, '0');
- final d = t.day.toString().padLeft(2, '0');
- final h = t.hour.toString().padLeft(2, '0');
- final mi = t.minute.toString().padLeft(2, '0');
- final s = t.second.toString().padLeft(2, '0');
- return '${t.year}-$mo-$d $h:$mi:$s';
- }
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final pos = position;
- final isLong = pos.side == OrderSide.long;
- final sideColor = isLong ? AppColors.rise : AppColors.fall;
- final pnlPositive = pos.unrealizedPnl >= 0;
- final pnlColor = pnlPositive ? AppColors.rise : AppColors.fall;
- final coinSymbol = _baseCoin(pos.symbol);
- final roeStr = '${pnlPositive ? '+' : ''}${formatAmount(pos.roe)}%';
- final qrData = inviteUrl;
- // 主题色变量
- final bgColors = isDark
- ? const [Color(0xFF1A1F2E), Color(0xFF0D1117)]
- : const [Color(0xFFF8F9FB), Color(0xFFEEF0F3)];
- final textPrimary = isDark ? Colors.white : const Color(0xFF1A1F2E);
- final textSecondary = isDark
- ? Colors.white.withAlpha(120)
- : const Color(0xFF1A1F2E).withAlpha(120);
- final textMuted = isDark
- ? Colors.white.withAlpha(80)
- : const Color(0xFF1A1F2E).withAlpha(80);
- final borderColor = isDark
- ? Colors.white.withAlpha(40)
- : const Color(0xFF1A1F2E).withAlpha(30);
- final qrFgColor = isDark ? Colors.white : Colors.black;
- final qrBgColor = isDark ? const Color(0xFF1A1F2E) : Colors.white;
- return Container(
- width: double.infinity,
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- colors: bgColors,
- ),
- borderRadius: BorderRadius.circular(16),
- ),
- clipBehavior: Clip.antiAlias,
- child: Padding(
- padding: const EdgeInsets.all(20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // LOGO + 品牌名
- Row(
- children: [
- Image.asset(
- 'assets/images/app_icon.png',
- height: 28,
- width: 28,
- errorBuilder: (_, __, ___) => const SizedBox.shrink(),
- ),
- const SizedBox(width: 8),
- Text(
- 'iBit',
- style: TextStyle(
- color: textPrimary,
- fontSize: 14,
- fontWeight: FontWeight.w700,
- letterSpacing: 0.5),
- ),
- ],
- ),
- const SizedBox(height: 14),
- // 币对 + 永续 tag
- Row(
- children: [
- Text(
- '${coinSymbol}USDT',
- style: TextStyle(
- color: textPrimary,
- fontSize: 22,
- fontWeight: FontWeight.w800),
- ),
- const SizedBox(width: 8),
- Container(
- padding:
- const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
- decoration: BoxDecoration(
- color: const Color(0xFFFFAB00),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Text(AppLocalizations.of(context)!.perpetual,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.w700)),
- ),
- ],
- ),
- const SizedBox(height: 4),
- // 方向 + 杠杆
- Text(
- '${isLong ? AppLocalizations.of(context)!.openLong : AppLocalizations.of(context)!.openShort} ${pos.leverage.toInt()}X',
- style: TextStyle(
- color: sideColor, fontSize: 15, fontWeight: FontWeight.w700),
- ),
- const SizedBox(height: 14),
- // 收益率(大字)
- Text(AppLocalizations.of(context)!.returnRate,
- style: TextStyle(color: textSecondary, fontSize: 12)),
- const SizedBox(height: 4),
- Text(roeStr,
- style: TextStyle(
- color: pnlColor,
- fontSize: 36,
- fontWeight: FontWeight.w800,
- letterSpacing: -0.5)),
- const SizedBox(height: 16),
- // 最新价 + 开仓均价
- Row(
- children: [
- Expanded(
- child: _ShareDataItem(
- label: AppLocalizations.of(context)!.latestPriceFull,
- value: formatAmount(pos.markPrice),
- textPrimary: textPrimary,
- textSecondary: textSecondary,
- ),
- ),
- Expanded(
- child: _ShareDataItem(
- label: AppLocalizations.of(context)!.openAvgPrice,
- value: formatAmount(pos.entryPrice),
- align: CrossAxisAlignment.end,
- textPrimary: textPrimary,
- textSecondary: textSecondary,
- ),
- ),
- ],
- ),
- const SizedBox(height: 10),
- // 时间
- Text(_formatNow(),
- style: TextStyle(color: textMuted, fontSize: 11)),
- const SizedBox(height: 14),
- // 分隔线
- Divider(color: borderColor, height: 1),
- const SizedBox(height: 14),
- // 邀请码 + 二维码
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (inviteCode != null)
- RichText(
- text: TextSpan(
- style: const TextStyle(fontSize: 15),
- children: [
- TextSpan(
- text: AppLocalizations.of(context)!
- .inviteCodeLabel,
- style: TextStyle(color: textSecondary),
- ),
- TextSpan(
- text: inviteCode!,
- style: const TextStyle(
- color: AppColors.brand,
- fontWeight: FontWeight.w700),
- ),
- ],
- ),
- ),
- const SizedBox(height: 4),
- Text(AppLocalizations.of(context)!.registerAndEarnRebate,
- style: TextStyle(color: textMuted, fontSize: 12)),
- ],
- ),
- ),
- Container(
- decoration: BoxDecoration(
- border: Border.all(color: borderColor, width: 1),
- borderRadius: BorderRadius.circular(6),
- ),
- padding: const EdgeInsets.all(4),
- child: qrData != null
- ? QrImageView(
- data: qrData,
- version: QrVersions.auto,
- size: 80,
- eyeStyle: QrEyeStyle(
- eyeShape: QrEyeShape.square,
- color: qrFgColor,
- ),
- dataModuleStyle: QrDataModuleStyle(
- dataModuleShape: QrDataModuleShape.square,
- color: qrFgColor,
- ),
- backgroundColor: qrBgColor,
- errorCorrectionLevel: QrErrorCorrectLevel.M,
- )
- : const SizedBox(width: 80, height: 80),
- ),
- ],
- ),
- ],
- ),
- ),
- );
- }
- }
- class _ShareDataItem extends StatelessWidget {
- const _ShareDataItem({
- required this.label,
- required this.value,
- required this.textPrimary,
- required this.textSecondary,
- this.align = CrossAxisAlignment.start,
- });
- final String label;
- final String value;
- final Color textPrimary;
- final Color textSecondary;
- final CrossAxisAlignment align;
- @override
- Widget build(BuildContext context) {
- return Column(
- crossAxisAlignment: align,
- children: [
- Text(label, style: TextStyle(color: textSecondary, fontSize: 11)),
- const SizedBox(height: 2),
- Text(value,
- style: TextStyle(
- color: textPrimary, fontSize: 13, fontWeight: FontWeight.w600)),
- ],
- );
- }
- }
- // ── 合约骨架屏 ──────────────────────────────────────────────
- /// 持仓/委托 Tab 列表骨架:[rows] 行卡片占位
- class _TabShimmer extends StatelessWidget {
- const _TabShimmer({required this.rows});
- final int rows;
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
- child: Column(
- children: List.generate(
- rows,
- (i) => Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Container(
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(8),
- ),
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 标题行:币对 + 方向标签 + 浮盈
- Row(children: [
- shimmerBox(80, 14),
- const SizedBox(width: 8),
- shimmerBox(44, 18, radius: 4),
- const Spacer(),
- shimmerBox(70, 14),
- ]),
- const SizedBox(height: 10),
- // 数据行
- Row(children: [
- Expanded(child: shimmerBox(double.infinity, 11)),
- const SizedBox(width: 12),
- Expanded(child: shimmerBox(double.infinity, 11)),
- const SizedBox(width: 12),
- Expanded(child: shimmerBox(double.infinity, 11)),
- ]),
- const SizedBox(height: 8),
- Row(children: [
- Expanded(child: shimmerBox(double.infinity, 11)),
- const SizedBox(width: 12),
- Expanded(child: shimmerBox(double.infinity, 11)),
- const SizedBox(width: 12),
- Expanded(child: shimmerBox(double.infinity, 11)),
- ]),
- const SizedBox(height: 10),
- // 操作按钮行
- Row(children: [
- shimmerBox(56, 24, radius: 4),
- const SizedBox(width: 8),
- shimmerBox(56, 24, radius: 4),
- const SizedBox(width: 8),
- shimmerBox(56, 24, radius: 4),
- ]),
- ],
- ),
- ),
- )),
- ),
- ),
- );
- }
- }
- /// 资产面板骨架
- class _AssetShimmer extends StatelessWidget {
- const _AssetShimmer();
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(100, 12),
- const SizedBox(height: 8),
- shimmerBox(160, 26),
- const SizedBox(height: 14),
- Row(children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(60, 11),
- const SizedBox(height: 6),
- shimmerBox(80, 14),
- ])),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(60, 11),
- const SizedBox(height: 6),
- shimmerBox(80, 14),
- ])),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- shimmerBox(60, 11),
- const SizedBox(height: 6),
- shimmerBox(80, 14),
- ])),
- ]),
- ],
- ),
- ),
- );
- }
- }
- class _FuturesShimmer extends StatelessWidget {
- const _FuturesShimmer();
- @override
- Widget build(BuildContext context) {
- return AppShimmer(
- child: SingleChildScrollView(
- physics: const NeverScrollableScrollPhysics(),
- child: Column(
- children: [
- // 上半:左侧下单区 + 右侧盘口
- SizedBox(
- height: 400,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // 左侧下单区骨架
- Expanded(
- flex: 55,
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 开多/开空 tab
- Row(children: [
- shimmerBox(70, 30, radius: 6),
- const SizedBox(width: 8),
- shimmerBox(70, 30, radius: 6),
- ]),
- const SizedBox(height: 12),
- // 价格输入框
- shimmerFill(44, radius: 8),
- const SizedBox(height: 10),
- // 数量输入框
- shimmerFill(44, radius: 8),
- const SizedBox(height: 10),
- // 滑块
- shimmerFill(20, radius: 10),
- const SizedBox(height: 16),
- // 按钮
- Row(children: [
- Expanded(child: shimmerFill(40, radius: 8)),
- const SizedBox(width: 8),
- Expanded(child: shimmerFill(40, radius: 8)),
- ]),
- const SizedBox(height: 16),
- // 数据行
- ...List.generate(
- 3,
- (_) => Padding(
- padding: const EdgeInsets.only(bottom: 8),
- child: Row(
- mainAxisAlignment:
- MainAxisAlignment.spaceBetween,
- children: [
- shimmerBox(60, 11),
- shimmerBox(70, 11),
- ],
- ),
- )),
- ],
- ),
- ),
- ),
- // 右侧盘口骨架
- Expanded(
- flex: 45,
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 8, vertical: 12),
- child: Column(
- children: [
- // 盘口 header
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- shimmerBox(40, 11),
- shimmerBox(60, 11),
- shimmerBox(40, 11),
- ],
- ),
- const SizedBox(height: 8),
- // 盘口行
- ...List.generate(
- 12,
- (_) => Padding(
- padding:
- const EdgeInsets.symmetric(vertical: 4),
- child: Row(
- children: [
- Expanded(
- child: shimmerBox(
- double.infinity, 11)),
- const SizedBox(width: 6),
- Expanded(
- child: shimmerBox(
- double.infinity, 11)),
- ],
- ),
- )),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- // 下半:持仓/委托区
- Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // tab 行
- Row(children: [
- shimmerBox(60, 28, radius: 6),
- const SizedBox(width: 8),
- shimmerBox(60, 28, radius: 6),
- const SizedBox(width: 8),
- shimmerBox(60, 28, radius: 6),
- ]),
- const SizedBox(height: 16),
- // 空状态占位
- Center(child: shimmerBox(120, 14)),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
- }
- /// 测量子组件渲染高度,每帧变化时回调。
- class _SizeReporter extends StatefulWidget {
- const _SizeReporter({required this.child, required this.onHeight});
- final Widget child;
- final ValueChanged<double> onHeight;
- @override
- State<_SizeReporter> createState() => _SizeReporterState();
- }
- class _SizeReporterState extends State<_SizeReporter> {
- double? _lastHeight;
- @override
- Widget build(BuildContext context) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) return;
- final rb = context.findRenderObject() as RenderBox?;
- if (rb == null || !rb.hasSize) return;
- final h = rb.size.height;
- if (h != _lastHeight) {
- _lastHeight = h;
- widget.onHeight(h);
- }
- });
- return widget.child;
- }
- }
|